From ee4e83b5c83c9bc72febe51323e08538a5b0b71b Mon Sep 17 00:00:00 2001 From: Cryptotomte Date: Fri, 3 Jul 2026 04:04:20 +0200 Subject: [PATCH 1/4] docs(spec): level sensors, reservoir-pump capability flag and INA226 (006) Spec-kit artifact set for PR-05: feature spec with CP1 clarification (FW-3 settle-gate in the abstraction now, rail control deferred to the rev2 board profile/PR-14), research R1-R10 (per-sensor ILevelSensor with 300 ms stability-window debounce; BOARD_HAS_RESERVOIR_PUMP with compile-time pin enforcement; Ina226Sensor with TI identity check and datasheet scaling; II2cBus writeRegister16 extension sanctioned by the 005 contract), data model, contracts, quickstart and 27 tasks. Co-Authored-By: Claude Fable 5 --- .specify/feature.json | 2 +- .../checklists/requirements.md | 42 +++ .../contracts/interfaces.md | 135 +++++++ specs/006-level-sensors-ina226/data-model.md | 87 +++++ specs/006-level-sensors-ina226/plan.md | 141 +++++++ specs/006-level-sensors-ina226/quickstart.md | 51 +++ specs/006-level-sensors-ina226/research.md | 146 ++++++++ specs/006-level-sensors-ina226/spec.md | 347 ++++++++++++++++++ specs/006-level-sensors-ina226/tasks.md | 78 ++++ 9 files changed, 1028 insertions(+), 1 deletion(-) create mode 100644 specs/006-level-sensors-ina226/checklists/requirements.md create mode 100644 specs/006-level-sensors-ina226/contracts/interfaces.md create mode 100644 specs/006-level-sensors-ina226/data-model.md create mode 100644 specs/006-level-sensors-ina226/plan.md create mode 100644 specs/006-level-sensors-ina226/quickstart.md create mode 100644 specs/006-level-sensors-ina226/research.md create mode 100644 specs/006-level-sensors-ina226/spec.md create mode 100644 specs/006-level-sensors-ina226/tasks.md diff --git a/.specify/feature.json b/.specify/feature.json index 2b5ef90..cb29409 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1 +1 @@ -{"feature_directory":"specs/005-bme280-i2c"} \ No newline at end of file +{"feature_directory":"specs/006-level-sensors-ina226"} \ No newline at end of file diff --git a/specs/006-level-sensors-ina226/checklists/requirements.md b/specs/006-level-sensors-ina226/checklists/requirements.md new file mode 100644 index 0000000..c649e2d --- /dev/null +++ b/specs/006-level-sensors-ina226/checklists/requirements.md @@ -0,0 +1,42 @@ +# Specification Quality Checklist: Level Sensors, Single-Pump Capability Flag and INA226 Power Telemetry + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-07-02 +**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 + +- Domain-necessary technical constants (GPIO 32/33, address 0x40, 5 mΩ, 500 ms + settle, capability-flag names) are hardware/parity facts from the checklist, the + rev2 design notes and FW-3/FW-5 — the observable contract, precedent from specs + 004/005. +- FR-004 references an open scope split (FW-3 rail control now vs PR-14) resolved + in the Clarifications section during /speckit-clarify. +- The stale master-PRD FR5 sentence is explicitly superseded by parity-checklist + line 96 (FR-002) to prevent re-importing the pre-fix polarity claim. +- Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan` diff --git a/specs/006-level-sensors-ina226/contracts/interfaces.md b/specs/006-level-sensors-ina226/contracts/interfaces.md new file mode 100644 index 0000000..7d7189b --- /dev/null +++ b/specs/006-level-sensors-ina226/contracts/interfaces.md @@ -0,0 +1,135 @@ +# Interface Contracts: Level Sensors, Capability Flag and INA226 + +Normative contracts for this feature's interfaces, console commands and board +flags. Style follows specs/004 and 005. + +## `ILevelSensor` (interfaces component, header-only, no IDF includes) + +``` +class ILevelSensor { + virtual void update() = 0; // poll driver; owner calls at its cadence + virtual bool isValid() = 0; // false during settle + debounce warm-up + virtual bool isWaterPresent() = 0; // logical, polarity-absorbed, debounced + virtual bool rawState() = 0; // undebounced pin level (diagnostics) + virtual void notifyPowerOn() = 0; // restart settle gating (FW-3 hook) +}; +``` + +Contract: + +- `update()` samples the raw input and advances the settle/debounce state machine + (data-model.md). No update ⇒ no state change: validity and state are functions + of the update stream, never of wall-clock reads alone. +- `isWaterPresent()` is meaningful only when `isValid()` — consumers gate on + validity (PR-11's truth table treats invalid as "do not act"). Before the first + stable window and during settle gating: `isValid()` false. +- Polarity is fully absorbed here via board configuration (FW-5); no consumer ever + sees a raw-polarity value except through `rawState()` (diagnostics only). +- `notifyPowerOn()` re-arms settle gating (rev2 ≥500 ms; rev1 0 ms). Rail control + itself is PR-14 (CP1 decision A) — app_main calls this once at boot on rev2. +- Debounce: reported state changes only after `BOARD_LEVEL_DEBOUNCE_MS` of raw + stability; any flip restarts the window (deliberate divergence from legacy's + bare reads — parity-checklist §6 entry). +- Fail direction (pinned by host tests, checklist line 97): disconnected input is + pulled HIGH ⇒ rev1 reads "water present" (fill pump stays off), rev2 reads + "water absent" (drawing node does not pump). Both fail safe for their topology. + +## `IPowerSensor` (interfaces component, header-only, no IDF includes) + +``` +class IPowerSensor { + virtual bool initialize() = 0; // idempotent; lazy-capable + virtual bool read() = 0; // one snapshot: V, I, P refreshed together + virtual bool isAvailable() = 0; // real identity probe + virtual int getLastError() = 0; // 0/1/2 (family convention) + virtual float getBusVoltage() = 0; // V — last-good, NaN before first success + virtual float getCurrent() = 0; // A — signed; last-good, NaN before first + virtual float getPower() = 0; // W — last-good, NaN before first success +}; +``` + +Contract: identical validity family as `IEnvironmentalSensor` (gate on read(); +last-good values; NaN placeholders; error 1 = not found/identity mismatch, error 2 += communication failure after identification; lazy re-init; +uninitialize-on-bus-error → identity re-probe; `isAvailable()` probe never touches +the error code, a lazy init triggered by it owns its own error reporting). + +## `Ina226Sensor` (sensors component, pure logic — builds on linux) + +`Ina226Sensor(II2cBus&, uint8_t address, uint32_t shuntMilliOhm)` implements +`IPowerSensor`. Owns: identity check (0xFE == 0x5449, 0xFF == 0x2260), +configuration + calibration writes (data-model.md; register values verified +against TI SBOS547 at implementation), scaling math (1.25 mV / Current_LSB 0.5 mA / +25 × Current_LSB), signed current handling. Host-tested against `MockI2cBus` +with reference vectors (hand-computed from the datasheet formulas, derivations +cited in test comments). + +## `DebouncedLevelSensor` + `GpioLevelSensor` (sensors component) + +- `DebouncedLevelSensor` (pure): implements `ILevelSensor` over an injected raw + input source + `ITimeProvider`; all settle/debounce/polarity policy lives here. + Host-tested with scripted input sequences + `FakeTimeProvider`. +- `GpioLevelSensor` raw-input provider (target-only): configures the pin as input + with internal pull-up (both boards, R4) and reads it. No logic. + +## `LockedLevelSensor` / `LockedPowerSensor` (sensors component, header-only) + +Per-call mutex decorators, same pattern and same documented cross-call limitation +as `LockedEnvironmentalSensor` (TODO(PR-11) snapshot helper applies here too). + +## Mocks (sensors component, testing/, header-only) + +- `MockLevelSensor`: scripted `isValid`/`isWaterPresent`/`rawState` with + consistency helpers (`scriptValidState(present)`, `scriptInvalid()`) — must + express all four PR-11 truth-table combinations across two instances, including + low-dry+high-wet. +- INA226 tests reuse `MockI2cBus` (extended for 16-bit writes, R6). + +## `II2cBus` extension (interfaces component — modifies PR-03's seam per its contract) + +- NEW virtual `writeRegister16(addr7, reg, uint16_t value)`: register pointer + + two data bytes **big-endian in one transaction**. Same error semantics as + `writeRegister` (false on NACK/error/timeout, no retries). +- Non-virtual convenience `readRegister16(addr7, reg, uint16_t &out)` implemented + in the header over `readRegisters(..., 2)`, big-endian decode. +- `EspI2cBus` and `MockI2cBus` updated accordingly; `MockI2cBus` records 16-bit + writes into the per-address byte map (big-endian) so existing byte assertions + remain valid. BME280 call sites are untouched. + +## Board contract (board.h) + +Per data-model.md table. Enforcement: `BOARD_HAS_RESERVOIR_PUMP=0` ⇒ +`BOARD_PIN_RESERVOIR_PUMP` undefined (compile error on unguarded use — RS485-DE +pattern); consistency asserts flag⇒pin; existing reservoir-pin sanity checks +flag-guarded; level pins distinct from each other/I2C/RS485 pins (compile-time). + +## Console commands (main/diag_console, thin wrappers) + +``` +level # both sensors: logical + raw + validity (both boards) +power # INA226 V/I/P or ERROR + hint (BOARD_HAS_INA226 only) +``` + +- `level` output must distinguish "not yet valid" from wet/dry (SC-005/FR-012). +- `power` failure output distinguishes error 1 (not found) from 2 (read failed). +- `pump reservoir ...` registration is compiled out on `BOARD_HAS_RESERVOIR_PUMP=0` + builds — rev2 console lists exactly one pump (PR-14 contract). + +## Wiring (app_main) + +Function-local statics after `pumps_force_off()` (which becomes capability-aware: +only existing pumps are forced off — the invariant "every pump that exists is OFF +first" is unchanged). Level sensors: two `GpioLevelSensor` + `DebouncedLevelSensor` ++ `Locked*` wrappers; `update()` called from the 10 Hz main loop; `notifyPowerOn()` +once at boot on rev2. INA226 (rev2): `Ina226Sensor` on the SAME `EspI2cBus` +instance as the BME280 (bus-sharing contract from PR-03 — never a second bus). + +## Deliberate divergences from legacy (parity-checklist §6 candidates) + +1. **Debounce** — legacy reads bare pins every loop pass; new: stability-window + debounce with explicit validity. +2. **Not-yet-valid state** — legacy has no concept; new: settle + warm-up gating + (FW-3) distinct from wet/dry. +3. **INA226 identity check** — new capability (no legacy INA226 at all). +4. **Reservoir pump capability flag** — rev2 compiles the pump out entirely + (single-pump decision); legacy always has both. diff --git a/specs/006-level-sensors-ina226/data-model.md b/specs/006-level-sensors-ina226/data-model.md new file mode 100644 index 0000000..044dc04 --- /dev/null +++ b/specs/006-level-sensors-ina226/data-model.md @@ -0,0 +1,87 @@ +# Data Model: Level Sensors, Capability Flag and INA226 + +## Level reading (per sensor: low mark GPIO 32, high mark GPIO 33) + +| Field | Type | Meaning | +|-------|------|---------| +| logical water-present | bool | polarity-mapped, debounced state (board owns polarity: rev1 active HIGH, rev2 active LOW — FW-5) | +| raw input | bool | current pin level, undebounced (diagnostics only) | +| valid | bool | false during debounce warm-up (before first stable window) and settle gating (FW-3) | + +State machine (`DebouncedLevelSensor`): + +``` +POWER_ON/CONSTRUCT ──▶ SETTLING (until settle_ms elapsed; rev1: 0) +SETTLING ──▶ WARMUP (raw sampled; valid=false until raw stable for debounce_ms) +WARMUP ──▶ TRACKING (valid=true; state = stable raw ⊕ polarity) +TRACKING: raw flip starts a stability window; reported state changes only after + debounce_ms of stability; every flip restarts the window. +notifyPowerOn() from any state ──▶ SETTLING (readings invalid again). +``` + +Time from injected `ITimeProvider` (host-testable with `FakeTimeProvider`). + +## Board capability set (board.h) + +| Macro | rev1 | rev2 | Notes | +|-------|------|------|-------| +| `BOARD_HAS_RESERVOIR_PUMP` | 1 | 0 | NEW — single-pump decision (master PRD FR4, final 2026-06-10); flag 0 ⇒ `BOARD_PIN_RESERVOIR_PUMP` deliberately undefined (compile-error enforcement, RS485-DE pattern) | +| `BOARD_PIN_LEVEL_LOW` / `BOARD_PIN_LEVEL_HIGH` | 32 / 33 | 32 / 33 | parity checklist line 95 (rev1); rev2 provisional until SYNC1 | +| `BOARD_LEVEL_ACTIVE_LOW` | 0 | 1 | FW-5 (2N7002 inverter) | +| `BOARD_LEVEL_DEBOUNCE_MS` | 300 | 300 | deliberate divergence (legacy: none) | +| `BOARD_LEVEL_SETTLE_MS` | 0 | 500 | FW-3 (XKC-Y26 response after rail power-on) | +| `BOARD_HAS_INA226` | 0 | 1 | existing | +| `BOARD_INA226_ADDR` | — | 0x40 | pump monitor, A0=A1=GND; 0x41 reserved (DNP solar), 0x76/0x77 BME280 — address map comment | + +Sanity checks: flag/pin consistency asserts (reservoir pump, level pins distinct +from each other and from I2C/RS485 pins), `#if BOARD_HAS_RESERVOIR_PUMP` guards on +existing reservoir-pin asserts. + +## Power reading (INA226, rev2) + +| Field | Type | Derivation | +|-------|------|-----------| +| bus voltage | float V | reg 0x02 (u16) × 1.25 mV | +| current | float A | reg 0x04 (s16) × Current_LSB (0.5 mA) — signed, never wrapped | +| power | float W | reg 0x03 (u16) × 25 × Current_LSB | + +Validity: established contract — gate on read(), NaN placeholders before first +success, last-good after failure; errors 0 = OK, 1 = not found (no ACK at 0x40 or +identity mismatch), 2 = read/communication failure after identification. + +## INA226 register map (used subset — 16-bit big-endian; verify against TI SBOS547 at implementation) + +| Reg | Name | Use | +|-----|------|-----| +| 0x00 | Configuration | RST; AVG; VBUSCT; VSHCT; MODE=0b111 (continuous shunt+bus). POR default 0x4127 | +| 0x02 | Bus Voltage | LSB 1.25 mV | +| 0x03 | Power | LSB 25 × Current_LSB | +| 0x04 | Current | signed, LSB = Current_LSB | +| 0x05 | Calibration | CAL = 0.00512 / (Current_LSB × R_shunt) ⇒ 2048 @ 0.5 mA, 5 mΩ | +| 0xFE | Manufacturer ID | must read 0x5449 ("TI") | +| 0xFF | Die ID | must read 0x2260 | + +Registers 0x01 (shunt voltage), 0x06/0x07 (Mask/Enable, Alert) unused — ALERT pin +NC on rev2 (design notes §5.2.2). + +Kconfig: `CONFIG_WS_INA226_SHUNT_MILLIOHM` int default 5 (rev2 BOM R1 = 5 mΩ 2512). + +## I2C seam extension (from PR-03's II2cBus) + +- `writeRegister16(addr7, reg, uint16_t)` — NEW virtual: pointer + 2 data bytes + big-endian in ONE transaction (INA226 config/calibration writes; not composable + from single-byte writes). +- `readRegister16(addr7, reg, uint16_t&)` — non-virtual convenience over + `readRegisters(addr, reg, buf, 2)` with big-endian decode. +- `EspI2cBus`: implements the virtual via a 3-byte `i2c_master_transmit`; + `MockI2cBus`: 16-bit writes recorded into the same per-address byte map + (big-endian) so byte-level assertions stay valid. + +## Concurrency + +Level sensors and the power sensor are unsynchronized by design; cross-task access +through `LockedLevelSensor`/`LockedPowerSensor` decorators (console REPL + main +loop now; PR-09/PR-11 later). Level `update()` is called from the main loop at +10 Hz; INA226 reads are on-demand (console; PR-09 API later) — bus transactions +serialize at the i2c_master driver layer (PR-03 research R3) plus EspI2cBus's +mutex-guarded handle table. diff --git a/specs/006-level-sensors-ina226/plan.md b/specs/006-level-sensors-ina226/plan.md new file mode 100644 index 0000000..f2c08d5 --- /dev/null +++ b/specs/006-level-sensors-ina226/plan.md @@ -0,0 +1,141 @@ +# Implementation Plan: Level Sensors, Single-Pump Capability Flag and INA226 Power Telemetry + +**Branch**: `006-level-sensors-ina226` | **Date**: 2026-07-02 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `specs/006-level-sensors-ina226/spec.md` + +## Summary + +Three riders on one PR, per the mini-PRD: (1) XKC-Y26 level sensors behind a new +`ILevelSensor` interface — polarity owned by the board profile (FW-5: rev1 active +HIGH, rev2 active LOW via 2N7002), stability-window debounce (300 ms, deliberate +divergence from legacy's bare reads) and FW-3 settle gating (rev2 500 ms; +rail *control* deferred to PR-14 per CP1 decision A); (2) the +`BOARD_HAS_RESERVOIR_PUMP` capability flag (rev1=1, rev2=0 — single-pump decision) +with compile-time enforcement (reservoir pin undefined on rev2, RS485-DE pattern) +rippling through app_main/diag_console; (3) `Ina226Sensor` raw power telemetry +(bus V / signed current / power) on the shared I2C bus at 0x40, identity-checked, +datasheet-formula scaling from a Kconfig shunt value (default 5 mΩ) — rev2 only, +host-tested everywhere. The PR-03 `II2cBus` seam is extended with a 16-bit write +(one-transaction pointer+2-byte, contract-sanctioned). Decisions: [research.md](research.md). + +## Technical Context + +**Language/Version**: C++ (~C++23, RAII, no Arduino) on ESP-IDF v6.0.1 (pinned +docker image) + +**Primary Dependencies**: none new — GPIO via `esp_driver_gpio` (already used), +I2C via the existing shared `EspI2cBus` (PR-03), esp_console. `dependencies.lock` +unchanged. + +**Storage**: N/A (readings RAM-only; API/logging exposure is PR-09) + +**Testing**: host tests on the linux preview target (existing harness; exit code = +failures; CI `target: linux`); HIL checklist on the rev1 rig at CP3 (level sensors ++ capability regression; INA226 hardware validation deferred to PR-14) + +**Target Platform**: ESP32-WROOM-32E, dual board targets via Kconfig; rev2 pins +provisional until SYNC1 + +**Project Type**: extension of existing `firmware/` components (interfaces, +sensors, board) + main wiring + +**Performance Goals**: level `update()` = one GPIO read at 10 Hz (negligible); +INA226 reads on demand (console) — two 2-byte I2C reads per query at 100 kHz + +**Constraints**: parity checklist §3 lines 95–97 (pins, pull-ups, measured +polarity, fail direction); FW-3/FW-5 consumed here; single-pump decision (master +PRD FR4); safety invariant (existing pumps forced OFF at boot) must hold +capability-aware on both boards; implementation starts only after PR #11 merges +(code dependency on II2cBus/EspI2cBus + file overlap — research R10) + +**Scale/Scope**: 2 level-sensor instances, 1 INA226 device (0x40; 0x41 reserved), +3 new interfaces (`ILevelSensor`, `IPowerSensor`, +II2cBus extension), 2 console +commands, ~7 board macros, no new tasks (main-loop polling) + +## Constitution Check + +*GATE: evaluated pre-Phase 0, re-checked post-Phase 1 — PASS (no violations).* + +- **I. Safety First**: PASS — pump force-off becomes capability-aware but the + invariant (every existing pump OFF first) is unchanged and stays host-tested; + level-sensor validity gating (not-yet-valid ≠ water absent) is designed so + PR-11's fail-safe cannot mistake a settling sensor for an empty reservoir; + fail directions per board pinned by host tests (checklist line 97). No + protection/alarm logic rides on INA226 (out of scope by PRD). +- **II. Host-Testability**: PASS — all policy (debounce, settle, polarity, + scaling, identity, error paths) in pure classes over injected seams + (raw-input + ITimeProvider; II2cBus); hardware classes are logic-free + (GpioLevelSensor raw read; EspI2cBus extension); mocks for PR-11 included. +- **III. Reproducible Builds**: PASS — no new managed dependencies; one new + Kconfig int; both targets in CI from clean checkout. +- **IV. Frozen Legacy**: PASS — legacy is read-only reference. +- **V. Checkpoint-Gated Workflow**: PASS — CP1 held (FW-3 split → A); stops at + CP2; implementer subagent after #11 merges; review + CP3 before push. +- **VI. English Outward**: PASS. +- **Additional constraints**: board differences only via board macros/Kconfig + (single `#if BOARD_HAS_*` sites in wiring code, none in logic); `ESP_LOG*` + tags; include guards; no partition changes; no non-trivial global constructors. + +## Project Structure + +### Documentation (this feature) + +```text +specs/006-level-sensors-ina226/ +├── spec.md +├── plan.md # This file +├── research.md # Phase 0 — R1–R10 +├── data-model.md # Phase 1 — states, board table, registers, scaling +├── contracts/ +│ └── interfaces.md # Phase 1 — ILevelSensor, IPowerSensor, II2cBus ext, console +├── quickstart.md # Phase 1 — validation guide +├── checklists/ +│ └── requirements.md +└── tasks.md # Phase 2 (/speckit-tasks) +``` + +### Source Code (repository root; against post-#11 file state) + +```text +firmware/ +├── components/ +│ ├── interfaces/include/interfaces/ +│ │ ├── ILevelSensor.h # NEW +│ │ ├── IPowerSensor.h # NEW +│ │ └── II2cBus.h # + writeRegister16 (virtual), readRegister16 (helper) +│ ├── sensors/ +│ │ ├── CMakeLists.txt # + DebouncedLevelSensor.cpp, Ina226Sensor.cpp (all targets), +│ │ │ # GpioLevelSensor.cpp (target-only) +│ │ ├── include/sensors/ +│ │ │ ├── DebouncedLevelSensor.h # pure: settle/debounce/polarity state machine +│ │ │ ├── GpioLevelSensor.h # target-only raw input (pull-up config) +│ │ │ ├── Ina226Sensor.h # pure: identity/config/cal/scaling +│ │ │ ├── LockedLevelSensor.h # decorator +│ │ │ ├── LockedPowerSensor.h # decorator +│ │ │ ├── EspI2cBus.h/.cpp # + writeRegister16 +│ │ │ └── testing/ +│ │ │ ├── MockLevelSensor.h # PR-11 truth-table capable +│ │ │ └── MockI2cBus.h # + 16-bit write scripting +│ │ └── src/ (…as above) +│ └── board/include/board/board.h # BOARD_HAS_RESERVOIR_PUMP, level macros, +│ # INA226 addr, guarded sanity checks +├── main/ +│ ├── app_main.cpp # capability-aware pump wiring; level sensors +│ │ # (10 Hz update); INA226 on shared bus (rev2) +│ ├── diag_console.cpp/.h # + level, power; pump reservoir gated +│ └── Kconfig.projbuild # + CONFIG_WS_INA226_SHUNT_MILLIOHM (default 5) +└── test_apps/host/main/ + ├── test_level_sensor.cpp # NEW — debounce/settle/polarity/fail-direction/mock + ├── test_ina226.cpp # NEW — scaling vectors, identity, errors, 16-bit bus ext + ├── test_main.cpp / CMakeLists.txt # registration +``` + +**Structure Decision**: extend the `sensors` component (established home; one +concern per class, not per component — precedent: soil + BME280 already coexist); +interfaces stay header-only in `interfaces`. No new FreeRTOS task — level polling +rides the existing 10 Hz main loop (R8). + +## Complexity Tracking + +No constitution violations — intentionally empty. diff --git a/specs/006-level-sensors-ina226/quickstart.md b/specs/006-level-sensors-ina226/quickstart.md new file mode 100644 index 0000000..8059539 --- /dev/null +++ b/specs/006-level-sensors-ina226/quickstart.md @@ -0,0 +1,51 @@ +# Quickstart Validation: Level Sensors, Capability Flag and INA226 + +Prerequisites: docker (`espressif/idf:v6.0.1`), branch `006-level-sensors-ina226` +(implementation rebased on main AFTER PR #11 merged). + +## 1. Host tests (CI gate) + +```bash +cd firmware/test_apps/host +docker run --rm -v "$PWD/../..":/project -w /project/test_apps/host espressif/idf:v6.0.1 bash -c \ + "idf.py --preview set-target linux && idf.py build && ./build/pump_host_tests.elf" +``` + +Expected: exit 0; new suites cover debounce/settle/polarity/fail-direction +(`DebouncedLevelSensor` over scripted inputs + FakeTimeProvider), INA226 scaling +vectors + identity/absent/recovery paths (`Ina226Sensor` over MockI2cBus incl. +16-bit writes), mock coherence (MockLevelSensor four truth-table states), and all +pre-existing suites unchanged (SC-004 regression guard). + +## 2. Both board targets build green (CI gate) + +```bash +cd firmware +docker run --rm -v "$PWD":/project -w /project espressif/idf:v6.0.1 \ + idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.board.rev1_devkit" build +docker run --rm -v "$PWD":/project -w /project espressif/idf:v6.0.1 bash -c \ + "idf.py fullclean && rm -f sdkconfig && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.board.rev2' build" +``` + +Expected: both green; rev2 binary has no reservoir pump (compile-time), rev1 no +INA226 wiring (SC-001). `dependencies.lock` unchanged (no new managed deps). + +## 3. HIL on the rev1 bench rig (checklist created at implementation: checklists/hil.md) + +1. **Level status** — `level` shows both sensors; trigger low/high sensors wet/dry + → correct logical states (SC-003); record measured polarity in + `docs/parity-checklist.md` line 96 (FR5 bench task). +2. **Debounce** — hand-simulate chatter at a mark → exactly one reported + transition (SC-005). +3. **Fail direction** — disconnect a sensor → reads "water present" (rev1 + direction), logged/documented (checklist line 97). +4. **Pump regression** — `pump reservoir start/stop/status` unchanged; pumps OFF + at boot; `env`/`soil` commands still work (shared main-loop/console surface). +5. **INA226** — not HIL-testable on rev1 (no device); host-covered; hardware + validation deferred to PR-14 by the mini-PRD. + +## References + +- Contracts: [contracts/interfaces.md](contracts/interfaces.md) +- Board flags, registers, scaling: [data-model.md](data-model.md) +- Decisions: [research.md](research.md) diff --git a/specs/006-level-sensors-ina226/research.md b/specs/006-level-sensors-ina226/research.md new file mode 100644 index 0000000..cd58f49 --- /dev/null +++ b/specs/006-level-sensors-ina226/research.md @@ -0,0 +1,146 @@ +# Phase 0 Research: Level Sensors, Capability Flag and INA226 + +Decisions resolving the open items from the spec and the pre-spec research report. +Facts cite the frozen legacy code, `docs/parity-checklist.md`, the rev2 design notes +(`hardware/rev2/design-notes/`, untracked hardware track) and `docs/rev2-firmware-notes.md` +(FW-3/FW-5, design review 2026-07-02). + +## R1 — Level-sensor interface: per-sensor `ILevelSensor`, two instances + +**Decision**: One `ILevelSensor` interface (interfaces component, pure C++): logical +`isWaterPresent()`, `rawState()` (diagnostics), `isValid()` (false during debounce +warm-up and settle gating), `update()` (poll driver — called by the owner at its +cadence). Two instances (low mark GPIO 32, high mark GPIO 33). A pure +`DebouncedLevelSensor` implements it over an injected raw-input seam +(`IDigitalInput`-style single-method functor or a tiny interface — plan keeps this +minimal); the hardware class `GpioLevelSensor` provides the raw read (target-only). + +**Rationale**: per-sensor mirrors the actuators pattern (one WaterPump per pump), +lets PR-11 compose its truth table from two independent sensors (incl. the invalid +combination), and keeps the mock trivial. An aggregate "reservoir" type would bake +PR-11 semantics into this layer. + +**Alternatives**: two-sensor aggregate interface (rejected — reservoir semantics +belong to PR-11); reusing `IEnvironmentalSensor`-style read()/getters (rejected — +level sensors are polled state, not transactional reads; validity here is +gating-based, not error-code-based). + +## R2 — Debounce: N-consecutive-samples over a time window, restart on flip + +**Decision**: The debounced state changes only after the raw input has held a new +value for `BOARD_LEVEL_DEBOUNCE_MS` (default **300 ms** on both boards; any raw flip +restarts the window). Sampling is polling-based via `update()` with an injected +time source (`ITimeProvider`, established pattern) — cadence chosen by the owner +(main loop 10 Hz ⇒ ~3 consecutive samples). Before the first stable window +completes, `isValid()` is false. 300 ms sits well under the XKC-Y26's own response +(~500 ms class) and far above any electrical chatter; board-tunable via the board +header, not Kconfig (it is a hardware property, not a user preference). + +**Rationale**: deliberate divergence from legacy (bare `digitalRead`, checklist §6 +entry required); deterministic and host-testable with `FakeTimeProvider`. + +## R3 — Settle gating (FW-3, CP1 decision A) + +**Decision**: `DebouncedLevelSensor` exposes `notifyPowerOn()` (or is constructed +with a settle duration and starts gated): readings report invalid until +`BOARD_LEVEL_SETTLE_MS` after the most recent power-on event. rev1 = 0 (rail always +on — constructor starts ungated... rev1 also gets debounce warm-up, which subsumes +settle=0), rev2 = **500 ms** (FW-3). Rail *control* (IO25/`SENS_PWR_EN`) is PR-14; +this PR only guarantees the abstraction honors a power-on notification, and +app_main on rev2 calls it once at boot (rail is on by default at power-up per the +rev2 design; PR-14 wires real switching to it). + +**Rationale**: encodes the FW-3 invariant now so PR-14 is wiring, not redesign; +rev1 unaffected. + +## R4 — Fail direction per board: pinned as host-tested constants + +**Decision**: Internal pull-ups enabled on both boards (rev1 parity checklist line +95; rev2 redundant-but-harmless on top of external 10 kΩ per design notes §6.3). +Host tests pin the documented fail truths (checklist line 97): disconnected input +reads pulled-HIGH ⇒ rev1 (active HIGH) → "water present" → fill pump stays off; +rev2 (active LOW) → "water absent" → drawing node does not pump. Both fail safe for +their topology; the tests exist so a future polarity/pull change trips loudly. + +## R5 — `BOARD_HAS_RESERVOIR_PUMP`: RS485-DE enforcement pattern + +**Decision**: `board.h`: rev1 `#define BOARD_HAS_RESERVOIR_PUMP 1` + pin as today; +rev2 `#define BOARD_HAS_RESERVOIR_PUMP 0` and **`BOARD_PIN_RESERVOIR_PUMP` removed** +(deliberately undefined ⇒ unguarded reference = compile error, same as +`BOARD_PIN_RS485_DE`). Existing sanity checks referencing the reservoir pin +(`board.h:125-141`) get `#if BOARD_HAS_RESERVOIR_PUMP` guards + a new consistency +assert (flag=1 requires pin defined). `app_main`: `pumps_force_off()` and instance +wiring become capability-aware (rev2 forces off exactly the one existing pump — +QUIRK 2 target intact); `diag_console`: `pump reservoir` registration compiled out +on flag=0 (PR-14's "exactly one pump" check sees compile-time absence). Address +map comment (0x40 pump INA226, 0x41 solar reserved, 0x76/0x77 BME280) recorded in +the rev2 board profile. + +## R6 — II2cBus 16-bit extension: add `writeRegister16` + `readRegister16` helpers + +**Decision**: Extend `II2cBus` with `writeRegister16(addr7, reg, uint16_t value)` +(pointer + 2 data bytes big-endian, ONE transaction — required for INA226 config/ +calibration writes; not composable from single-byte writes) and, for symmetry and +call-site clarity, a non-virtual convenience `readRegister16` implemented on the +interface in terms of `readRegisters` (big-endian decode; INA226 reads are already +expressible as a 2-byte `readRegisters`). `EspI2cBus` implements the new virtual +via `i2c_master_transmit` (3-byte buffer); `MockI2cBus` gets scripting/recording +support for 16-bit writes (stored into the same per-address byte map, big-endian, +so existing byte-level assertions keep working). + +**Rationale**: sanctioned by the PR-03 contract ("PR-05 may extend the interface +or compose two 8-bit operations, its call"); one new virtual keeps the seam minimal +and BME280 code untouched. + +**Alternatives**: a generic `writeRegisters(addr, reg, buf, len)` (rejected — wider +than any current need; can supersede later if a third device family needs it). + +## R7 — `Ina226Sensor`: pure logic over II2cBus, BME280 architecture cloned + +**Decision**: `Ina226Sensor` (pure C++, builds on linux) implements a new +`IPowerSensor` interface (busVoltage V, current A signed, power W + the established +validity contract: gate on read(), last-good values NaN-initialized, error codes +0/1/2, lazy re-init, identity check at init, uninitialize-on-bus-error → re-probe). +Identity = manufacturer ID reg 0xFE == 0x5449 and die ID reg 0xFF == 0x2260. +Init sequence: identity → write config (AVG=16, VBUSCT=VSHCT=1.1 ms, MODE=0b111 +continuous shunt+bus — values TO BE VERIFIED against TI datasheet SBOS547 during +implementation, flagged) → write calibration `CAL = 0.00512 / (Current_LSB × +R_shunt)` with Current_LSB = 0.5 mA, R_shunt from Kconfig (mΩ, default 5) ⇒ CAL = +2048 at defaults (matches design-note operating point ~±16.4 A FS). Scaling: +bus V = raw × 1.25 mV; current = raw(signed) × Current_LSB; power = raw × 25 × +Current_LSB. Host tests pin these against hand-computed reference vectors. +No ALERT/limit usage (pin NC). Compiled per `BOARD_HAS_INA226` (the .cpp is pure +and could build everywhere, but the instance/wiring/console are rev2-only; +CMake keeps the pure file on all targets for host tests — rev1 target simply never +instantiates it, satisfying spec FR-011's "no INA226 configuration present" via +the board flag guards in wiring code). + +**Rationale**: maximum reuse of the proven BME280 pattern; datasheet-formula +scaling is exactly the host-testable math Constitution II wants. + +## R8 — Console + wiring + +**Decision**: `level` command (both boards): both sensors' logical/raw/validity. +`power` command (rev2/`BOARD_HAS_INA226` only): V/I/P or distinct error. Both thin +wrappers, registered before console start. Level sensors are polled from the main +loop (10 Hz, `update()` calls) — no new task (they are cheap GPIO reads; the 5 s +sensor task is BME280's transactional cadence, not a fit). Cross-task access: +`LockedLevelSensor` wrappers (console + future controller), same decorator pattern. +INA226 access from console only this PR → still wrapped (`LockedPowerSensor`) per +the established rule (console REPL + PR-09/PR-11 future readers). + +## R9 — Kconfig placement + +**Decision**: `CONFIG_WS_INA226_SHUNT_MILLIOHM` (int, default 5, range-checked) in +`firmware/main/Kconfig.projbuild` under the existing "WateringSystem" menu — +first non-board option; a sensors-component Kconfig would be invisible next to the +board choice users already know. Current_LSB stays a compile-time constant in the +driver (derived docs in header); promote to Kconfig only if a real need appears. + +## R10 — File overlap with PR-03 (#11) + +Implementation touches the same files as PR-03 (`sensors/CMakeLists.txt`, +`app_main.cpp`, `diag_console.*`, `test_apps` harness, `firmware/CLAUDE.md`, +`board.h`) **and depends on its code** (`II2cBus`/`EspI2cBus`). Hard gate: +implementation starts only after PR #11 merges; this spec/plan phase is +conflict-free. Plan is written against the 005 worktree's file state. diff --git a/specs/006-level-sensors-ina226/spec.md b/specs/006-level-sensors-ina226/spec.md new file mode 100644 index 0000000..1bf7f7c --- /dev/null +++ b/specs/006-level-sensors-ina226/spec.md @@ -0,0 +1,347 @@ +# Feature Specification: Level Sensors, Single-Pump Capability Flag and INA226 Power Telemetry + +**Feature Branch**: `006-level-sensors-ina226` + +**Created**: 2026-07-02 + +**Status**: Draft + +**Input**: User description: "Level sensors (XKC-Y26) with board-configured polarity, +debounce and mock behind a level-sensor interface; introduce the +BOARD_HAS_RESERVOIR_PUMP capability flag (rev1=1, rev2=0, single-pump decision); +Ina226Sensor raw power readings on the shared I2C bus, rev2 only — per +docs/prd/PR-05-level-sensors-ina226.md (authoritative mini-PRD; behavior ground truth +docs/parity-checklist.md §3 lines 95–97) plus hardware-driven requirements FW-3 and +FW-5 from the rev2 design review 2026-07-02." + +## Clarifications + +### Session 2026-07-02 + +- Q: FW-3 scope split — how much of the switched sensor rail (`SENS_PWR_EN`, rev2 + IO25) lands in this PR? → A: Settle-gate only: the level-sensor abstraction gets + a board-configured settle time (rev1 = 0 ms, rev2 = 500 ms) and reports + not-yet-valid during the window; actual rail control (IO25 driving, keep-rail-on + during watering runs) belongs to the rev2 board profile / PR-14 alongside + FW-1/FW-6. Rev1 has no switched rail, so more could not be HIL-verified now + anyway. Confirmed by Paul at Checkpoint 1. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Trustworthy water-level status on both boards (Priority: P1) + +Paul (or the future controller) asks the system whether the reservoir has water at +the low mark and at the full mark. The answer is correct on both board revisions even +though the electrical polarity is opposite (rev1 reads the XKC-Y26 directly = active +HIGH; rev2 goes through an inverter = active LOW): the board configuration owns the +polarity, application code only ever sees logical "water present at this mark". Brief +electrical chatter at the water line does not flip the answer back and forth, and a +sensor that has just been powered (rev2 switched sensor rail) is not trusted until it +has had time to settle. + +**Why this priority**: the level sensors gate every reservoir decision (PR-11's +fail-safe truth table) and FR5's bench-verified polarity is a phase-1 exit +requirement (`docs/parity-checklist.md` line 96: verify by measurement, not parity — +the legacy code reads active HIGH after the 2026-04-12 fix; the master PRD FR5 +sentence claiming otherwise is stale and superseded by the checklist). + +**Independent Test**: on the rig, dip/remove the sensors in water (or hand-trigger +them): console shows the correct logical state; polarity verified by bench +measurement and recorded in the parity checklist. + +**Acceptance Scenarios**: + +1. **Given** the rev1 rig with an XKC-Y26 on the low-level input, **When** water is + present at the sensor, **Then** the system reports "water present" (GPIO reads + HIGH; board configuration maps it to true) — and the measured polarity is + recorded in `docs/parity-checklist.md` line 96 as that line requires. +2. **Given** a rev2 build, **When** the same firmware logic runs, **Then** the + polarity mapping is inverted by board configuration alone (FW-5: 2N7002 inverter, + water present = GPIO LOW) — no application-level `#ifdef`s. +3. **Given** water sloshing at exactly the sensor mark, **When** the raw input + chatters, **Then** the reported logical state changes only after the input has + been stable for the configured debounce window (deliberate divergence: legacy has + no debounce). +4. **Given** a board with a switched sensor power rail (rev2), **When** the rail has + just been enabled, **Then** level readings are reported as not-yet-valid until + the board-configured settle time (FW-3: ≥500 ms) has elapsed. +5. **Given** the rig console, **When** the operator runs the level-status command, + **Then** both sensors' logical state (and raw pin state for troubleshooting) are + shown. + +--- + +### User Story 2 - One firmware, one-pump and two-pump boards (Priority: P2) + +An AI developer (or Paul) builds the firmware for rev2, which by final decision +(2026-06-10) is a single-pump node: there is no reservoir refill pump — the reservoir +is refilled manually until the central-reservoir unit exists. The rev2 binary +contains exactly one pump: no reservoir pump instance, no reservoir pump console +command, no dead reservoir-pump GPIO writes. The rev1 rig keeps both pumps and all +existing reservoir-pump behavior unchanged. Safety invariants hold on both: every +pump that exists on a board is forced OFF first thing at boot. + +**Why this priority**: the capability flag is the load-bearing decision of this PR — +PR-09 (API), PR-11 (controller) and PR-14 (rev2 bring-up, "exactly one pump in +console/API") all build on it. Getting the compile-time gating right now prevents a +class of rev2 bugs later. + +**Independent Test**: build both variants in CI; rev1 console has `pump reservoir`, +rev2 does not (compile-time absence, not a runtime error); both boards' existing +pump tests still pass. + +**Acceptance Scenarios**: + +1. **Given** a rev1 build, **When** the system boots, **Then** both pumps exist, + both are forced OFF at boot, and `pump reservoir start/stop/status` works as + before (no regression). +2. **Given** a rev2 build, **When** the firmware compiles, **Then** no reservoir + pump instance, pin reference or console registration exists — the reservoir pump + pin is deliberately undefined so any unguarded reference is a compile error + (same enforcement pattern as the RS485 direction pin). +3. **Given** a rev2 build, **When** the operator lists pump status, **Then** exactly + one pump is reported (PR-14 expectation). +4. **Given** either board, **When** the boot sequence runs, **Then** every pump that + exists on that board is driven OFF before anything else (existing invariant, + now capability-aware; QUIRK 2 target unchanged). + +--- + +### User Story 3 - Pump power telemetry on rev2 (Priority: P3) + +The rev2 board carries an INA226 current/voltage monitor on the pump's 12 V supply +(high-side 5 mΩ shunt). The firmware reads bus voltage, current and power as plain +values — visible on the console for bring-up and consumable by PR-09's API later. A +missing or unresponsive INA226 degrades gracefully (readings invalid, system runs +on); rev1 builds contain none of this code. + +**Why this priority**: raw telemetry only — no protection logic (dry-run/blockage +detection is a future PRD), no on-hardware validation (PR-14). It rides along now +because it shares this PR's I2C infrastructure. + +**Independent Test**: host tests drive the driver over a scripted bus (scaling math +against datasheet formulas); rev2 build compiles it, rev1 build excludes it; +absent-device behavior verified by host test (hardware validation deferred to +PR-14). + +**Acceptance Scenarios**: + +1. **Given** a scripted INA226 at address 0x40, **When** the driver reads it, + **Then** bus voltage, current and power come back correctly scaled from the raw + registers (shunt value and current resolution from build-time configuration; + default 5 mΩ per the rev2 BOM). +2. **Given** a responding device with the wrong identity, **When** the driver + initializes, **Then** the device is rejected and reported unavailable (identity + check, mirroring the BME280 chip-ID pattern). +3. **Given** no device at 0x40 (rev2 board without the part, or a fault), **When** + readings are requested, **Then** they are flagged invalid with a distinct error, + nothing crashes, and a later-appearing device recovers on a subsequent attempt + (lazy re-init, same contract family as the other sensors). +4. **Given** a rev1 build, **When** it compiles, **Then** no INA226 code or + configuration is present (`BOARD_HAS_INA226` = 0). +5. **Given** the rev2 console, **When** the operator runs the power-telemetry + command, **Then** voltage/current/power (or a distinct error) are shown — the + PR-14 bring-up path. + +--- + +### User Story 4 - Behavior testable without hardware (Priority: P4) + +An AI developer changes debounce, polarity mapping, capability gating or INA226 +scaling and runs the host suite in CI: level-sensor logic (polarity per board, +debounce, settle gating, fail-direction per board), INA226 register scaling and +error paths, and the mocks PR-11 will consume are all verified against scripted +inputs — no hardware, failures block merge. + +**Why this priority**: Constitution II. The level-sensor mock created here is the +direct input to PR-11's reservoir truth-table tests (both-wet / low-only / +both-dry / invalid). + +**Independent Test**: host suite passes on a hardware-less machine; the mock can +express every state PR-11's truth table needs, including the invalid combination. + +**Acceptance Scenarios**: + +1. **Given** scripted raw pin sequences, **When** the debounce logic runs, **Then** + state changes only after the configured stability window, and the pre-first- + stable-sample state is reported as not-yet-valid (never a guess). +2. **Given** both board polarity configurations, **When** the same logical scenario + is scripted, **Then** both produce identical logical results (polarity is fully + absorbed by configuration). +3. **Given** a disconnected-sensor simulation per board (rev1: pull-up reads HIGH = + "water present" → fill pump would stay off; rev2: pull-up reads HIGH = "water + absent" → drawing node would not pump), **When** the fail-direction tests run, + **Then** each board's documented fail-safe direction is pinned as a host-tested + truth (checklist line 97). +4. **Given** the INA226 datasheet scaling formulas, **When** the conversion math + runs on scripted register values, **Then** results match reference calculations + (including the 5 mΩ / ~0.5 mA-LSB rev2 operating point). +5. **Given** the level-sensor mock, **When** PR-11-style consumer tests script the + four truth-table states, **Then** all four (incl. low-dry+high-wet = invalid) + are expressible with coherent validity. + +--- + +### Edge Cases + +- Raw input chattering exactly at the debounce boundary: state must resolve + deterministically (stability window restarts on every flip); no oscillating + reported state. +- Settle-time gating (FW-3): a read during the settle window is "not yet valid" — + distinct from "water absent" (an early XKC-Y26 sample falsely reads absent; the + gate exists to prevent exactly that misread). +- The invalid combination low=dry + high=wet is REPORTED as-is (both sensors' + states with validity); interpreting it is PR-11's job — this layer never masks it. +- Sensor disconnected mid-operation: reported state follows the board's documented + fail direction (pull-ups); no crash, no flapping (debounce applies). +- INA226 present but bus contention with BME280/soil reads: transactions serialize + on the shared bus (established bus contract); neither sensor's cadence breaks. +- INA226 negative current (register is signed): reported correctly signed, not + wrapped — the pump should never regenerate, but the math must not lie. +- Reservoir pump commands on a rev2 build: absent at compile time — not a runtime + "unknown command" surprise for PR-14's "exactly one pump" check. +- Boot with the sensor rail off (rev2, future rail control): level readings remain + not-yet-valid rather than "absent" — fail-safe direction for a drawing node. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: Level-sensor functionality MUST be exposed through a + hardware-independent interface (pure C++, host-includable) reporting, per sensor + (low mark, high mark): logical water-present state, raw input state (diagnostics), + and an explicit validity signal (not-yet-valid before the first debounced sample + and during settle gating). Interface conventions follow the established sensor + contracts (consumers gate on validity; unsynchronized by design with the Locked + decorator pattern for cross-task access). +- **FR-002**: Logical polarity MUST be owned by the board configuration (FW-5): + rev1 = active HIGH (XKC-Y26 direct through non-inverting level shifter), rev2 = + active LOW (2N7002 inverter). Application code MUST contain no board conditionals + for polarity. Ground truth for verification is `docs/parity-checklist.md` + lines 95–97 — NOT the stale master-PRD FR5 sentence (superseded). +- **FR-003**: Level inputs MUST be sampled by polling with debounce: the reported + logical state changes only after the raw input has been stable for a configured + window (board-tunable; deliberate divergence — legacy reads bare pins with no + filtering). Before the first stable sample the state is not-yet-valid. +- **FR-004**: Readings MUST support settle-time gating (FW-3): a board-configured + time after sensor-power-on during which readings report not-yet-valid (rev2: + ≥500 ms per XKC-Y26 response time; rev1: 0 — sensors are permanently powered). + Actual power-rail control (`SENS_PWR_EN`, rev2 IO25) is OUT of scope — rev2 + board profile / PR-14 (CP1 decision, see Clarifications). +- **FR-005**: Pull configuration MUST follow the board: rev1 uses internal pull-ups + (parity, checklist line 95); rev2 has external pull-ups — internal ones stay + enabled on both (redundant-but-harmless, per the rev2 design notes). The + fail direction per board (disconnected sensor: rev1 reads "water present", + rev2 reads "water absent" — both fail safe for their pump topology) MUST be + documented and host-tested (checklist line 97). +- **FR-006**: The board component MUST expose a `BOARD_HAS_RESERVOIR_PUMP` + capability flag: 1 on rev1, 0 on rev2 (single-pump decision, master PRD FR4, + final 2026-06-10). On boards with flag 0 the reservoir pump pin MUST be + deliberately undefined so unguarded references fail at compile time (existing + RS485-DE enforcement pattern), and existing board sanity checks MUST be + flag-guarded accordingly. +- **FR-007**: All reservoir-pump wiring MUST be capability-gated: instance + creation, boot force-OFF, and console registration exist only when + `BOARD_HAS_RESERVOIR_PUMP` = 1. The boot fail-safe invariant (every existing + pump driven OFF first) MUST remain intact on both boards. Rev1 behavior is + unchanged (no regression); reservoir feature flags remain non-persisted (parity). +- **FR-008**: An INA226 driver MUST provide bus voltage, current and power as plain + readings over the shared I2C bus instance (established bus-sharing contract — + never a second bus), device address 0x40 (rev2 pump monitor, A0=A1=GND), with + conversion per the vendor datasheet: calibration derived from the + build-configurable shunt resistance (default 5 mΩ per the rev2 BOM) and current + resolution; 16-bit big-endian registers. +- **FR-009**: The INA226 driver MUST verify device identity at initialization + (manufacturer/die ID registers) and reject foreign devices (deliberate + divergence, mirrors the BME280 chip-ID check). +- **FR-010**: INA226 absence or failure MUST degrade gracefully: readings carry an + explicit validity signal with distinct errors (not-found vs read-failed, same + error-code family as the other sensors), no crash, lazy re-probe recovery on + later attempts. Alert/limit functionality is OUT of scope (ALERT pin is + unconnected on rev2). +- **FR-011**: INA226 code MUST be compiled out on boards with `BOARD_HAS_INA226` = + 0 (rev1); both board targets MUST build green in CI. +- **FR-012**: Serial console diagnostics MUST exist following the established + thin-wrapper pattern: a level-status command (both sensors: logical state, raw + state, validity) on both boards, and a power-telemetry command (voltage, current, + power or distinct error) on INA226-equipped boards — the HIL/bring-up + verification paths (PR-14 relies on the latter). +- **FR-013**: Mocks MUST exist for host tests: a level-sensor mock expressive + enough for PR-11's reservoir truth table (both-wet / low-only / both-dry / + invalid combination, with validity control and consistency helpers per the + established mock conventions) and a scripted-bus path for INA226 driver tests + (16-bit register support in the mock bus as needed). +- **FR-014**: Where the existing I2C bus seam cannot express INA226 transactions + (16-bit register writes are pointer + two data bytes in ONE transaction — not + composable from single-byte writes), the seam MUST be extended consistently + across the interface, the hardware implementation and the mock (the PR-03 + contract explicitly delegates this decision to this PR). + +### Key Entities + +- **Level reading**: per-sensor logical water-present state + raw input state + + validity (not-yet-valid during debounce warm-up/settle window); produced by + polling, consumed by console now, PR-11's controller later. +- **Board capability set**: `BOARD_HAS_RESERVOIR_PUMP` (new, rev1=1/rev2=0), + `BOARD_HAS_INA226` (existing, rev1=0/rev2=1), level-sensor polarity/settle/pull + parameters — the single source of truth application code configures itself from. +- **Power reading**: bus voltage, current (signed), power derived from INA226 + registers via shunt/current-LSB calibration; validity + error state; rev2 only. +- **I2C register seam**: the shared bus abstraction from PR-03, possibly extended + for 16-bit transactions; one instance, BME280 + INA226 as peers. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Both board targets build green in CI from clean checkout; the rev2 + binary provably contains no reservoir pump (compile-time absence) and the rev1 + binary no INA226 code. +- **SC-002**: Host suite covers: polarity mapping equivalence across both board + configs, debounce boundary behavior, not-yet-valid gating (warm-up + settle), + per-board fail-direction truths, INA226 scaling against datasheet reference + calculations, INA226 identity/absent/recovery paths, and mock coherence — zero + hardware, deterministic passes. +- **SC-003**: On the rev1 rig, the level-status console command reports correct + logical states for wet/dry at both marks on first attempt, and the measured + polarity is recorded in `docs/parity-checklist.md` line 96 (FR5 bench-verification + task closed for rev1). +- **SC-004**: Existing rev1 pump behavior unchanged: all pre-existing pump host + tests and console commands pass unmodified (regression guard). +- **SC-005**: Chatter at a level mark (hand-simulated on the rig) produces no + oscillating state at the console — exactly one transition per real state change. +- **SC-006**: PR-11 can consume the level-sensor mock for all four truth-table + states without modification (verified by a representative consumer-style host + test in this PR). + +## Assumptions + +- **Polling, not interrupts**: level inputs are polled (legacy pattern; PR-11's + controller is polling-based; debounce needs periodic sampling anyway). Polling + cadence is an implementation choice bounded by the debounce window. +- **Debounce default**: state accepted after stability across the configured window + (default on the order of a few hundred ms — slow physical process; exact value + fixed in plan, board-tunable). Legacy has none — documented divergence + (parity-checklist §6 entry). +- **Console surface**: `level` on both boards; power telemetry command named in + plan (rev2/`BOARD_HAS_INA226` builds only). Grammar follows the existing + serial-diagnostic contract style. +- **INA226 reads on demand** (console now, PR-09 API later): the device runs in + continuous-conversion mode configured at init; no periodic telemetry task in this + PR; no data-storage logging (PR-09 territory). Averaging/conversion-time settings + are plan-level choices documented against the datasheet. +- **Only the pump INA226 (0x40) is in scope** — the DNP solar INA226 (0x41) is + future power-track work; the I2C address map (0x40 pump, 0x41 solar reserved, + 0x76/0x77 BME280) is recorded in the board component for collision safety. +- **FW-1 (VBAT ADC) and FW-6 (IO35 input-only) are NOT this PR** — bookmarked for + the rev2 board profile / PR-14 (power-block telemetry). +- **Kconfig scope**: shunt resistance (mΩ, default 5) is the build-time + configuration; current LSB derived. Placement (project vs component Kconfig) is a + plan decision. +- **Interface shape** (per-sensor `ILevelSensor` × 2 vs a two-sensor aggregate) is + a plan decision; the spec's binding contract is per-sensor state + validity as in + FR-001. +- **Hardware-source caveat**: rev2 pin/part facts come from the in-flight hardware + track (`docs/rev2-firmware-notes.md`, `hardware/rev2/design-notes/` — untracked + on main, provisional until SYNC1); INA226 on-hardware validation is deferred to + PR-14 by the mini-PRD. diff --git a/specs/006-level-sensors-ina226/tasks.md b/specs/006-level-sensors-ina226/tasks.md new file mode 100644 index 0000000..738ab7d --- /dev/null +++ b/specs/006-level-sensors-ina226/tasks.md @@ -0,0 +1,78 @@ +# Tasks: Level Sensors, Single-Pump Capability Flag and INA226 Power Telemetry + +**Input**: Design documents from `specs/006-level-sensors-ina226/` +**Prerequisites**: plan.md, research.md (R1–R10), data-model.md, contracts/interfaces.md, quickstart.md + +**Tests**: host tests explicitly required (SC-002, Constitution II) — in scope, +existing harness conventions. + +**Organization**: grouped by user story (US1–US4 from spec.md). Hard gate +(research R10): implementation starts only after **PR #11 (005-bme280-i2c) has +merged** — this feature extends its `II2cBus`/`EspI2cBus`/`MockI2cBus` and shares +app_main/diag_console/test-harness files. + +## Phase 1: Setup + +- [ ] T001 Rebase branch `006-level-sensors-ina226` onto origin/main AFTER PR #11 merges; verify `interfaces/II2cBus.h`, `sensors/EspI2cBus.*`, `sensors/testing/MockI2cBus.h` and the `env` console command exist; verify host suite + both board targets build green from clean checkout (baseline) + +## Phase 2: Foundational + +- [ ] T002 [P] Create `ILevelSensor` in `firmware/components/interfaces/include/interfaces/ILevelSensor.h` — update/isValid/isWaterPresent/rawState/notifyPowerOn, contract doc comments per contracts/interfaces.md (validity gating, polarity absorption, fail-direction notes) +- [ ] T003 [P] Create `IPowerSensor` in `firmware/components/interfaces/include/interfaces/IPowerSensor.h` — established sensor validity family (initialize/read/isAvailable/getLastError 0/1/2, NaN-before-first-success getters: busVoltage/current/power) +- [ ] T004 Extend `II2cBus` in `firmware/components/interfaces/include/interfaces/II2cBus.h`: new virtual `writeRegister16(addr7, reg, uint16_t)` (big-endian, ONE transaction, doc: required by INA226 config/cal writes) + non-virtual `readRegister16` helper over readRegisters; update the PR-05 delegation note to point at this resolution +- [ ] T005 Implement `writeRegister16` in `firmware/components/sensors/src/EspI2cBus.cpp` + `include/sensors/EspI2cBus.h` (3-byte i2c_master_transmit, same lock/log discipline as existing methods) +- [ ] T006 Extend `MockI2cBus` in `firmware/components/sensors/include/sensors/testing/MockI2cBus.h`: 16-bit writes recorded big-endian into the per-address byte map + call log; scripting outcomes for 16-bit writes; host test for the extension in `firmware/test_apps/host/main/test_ina226.cpp` (mock-level roundtrip) +- [ ] T007 [P] Board flags in `firmware/components/board/include/board/board.h` per data-model.md table: `BOARD_HAS_RESERVOIR_PUMP` (rev1=1; rev2=0 + `BOARD_PIN_RESERVOIR_PUMP` REMOVED), `BOARD_PIN_LEVEL_LOW/HIGH` (32/33 both), `BOARD_LEVEL_ACTIVE_LOW` (0/1), `BOARD_LEVEL_DEBOUNCE_MS` (300), `BOARD_LEVEL_SETTLE_MS` (0/500), `BOARD_INA226_ADDR` (0x40 rev2 + address-map comment); flag-guard existing reservoir-pin sanity checks, add flag⇒pin consistency assert + level-pin distinctness asserts + +## Phase 3: User Story 1 — Trustworthy water-level status (P1) 🎯 MVP + +- [ ] T008 [US1] Implement `DebouncedLevelSensor` (pure) in `firmware/components/sensors/include/sensors/DebouncedLevelSensor.h` + `src/DebouncedLevelSensor.cpp`: injected raw-input source + ITimeProvider; settle gating (notifyPowerOn per FW-3, CP1 decision A) → warm-up → tracking with stability-window debounce (flip restarts window); polarity from constructor parameter (board macro at wiring site); state machine per data-model.md +- [ ] T009 [P] [US1] Host tests in `firmware/test_apps/host/main/test_level_sensor.cpp` (new suite `run_level_sensor_tests()`): debounce boundary (change only after window; flip restarts), warm-up not-yet-valid, settle gating (500 ms rev2 case incl. notifyPowerOn re-arm), polarity equivalence (same scenario both polarities → identical logical result), chatter → single transition +- [ ] T010 [P] [US1] Implement `GpioLevelSensor` raw-input provider (target-only) in `firmware/components/sensors/include/sensors/GpioLevelSensor.h` + `src/GpioLevelSensor.cpp`: input + internal pull-up (both boards, R4), no logic; CMakeLists linux-exclusion +- [ ] T011 [P] [US1] Implement `LockedLevelSensor` in `firmware/components/sensors/include/sensors/LockedLevelSensor.h` (per-call mutex decorator, established pattern + cross-call limitation note) +- [ ] T012 [US1] `level` console command: `diag_console_register_level(ILevelSensor &low, ILevelSensor &high)` in `firmware/main/diag_console.h/.cpp` — thin wrapper; output distinguishes not-yet-valid from wet/dry, shows logical + raw per sensor +- [ ] T013 [US1] Wire level sensors in `firmware/main/app_main.cpp`: two GpioLevelSensor + DebouncedLevelSensor (+Locked wrappers), polarity/settle from board macros, `notifyPowerOn()` at boot, `update()` calls in the 10 Hz main loop, console registration before start + +## Phase 4: User Story 2 — One firmware, one-pump and two-pump boards (P2) + +- [ ] T014 [US2] Capability-gate reservoir pump wiring in `firmware/main/app_main.cpp`: instance creation, boot force-OFF and any references under `#if BOARD_HAS_RESERVOIR_PUMP`; verify the force-off-first invariant reads correctly on both boards (rev2 forces off exactly the plant pump) +- [ ] T015 [US2] Gate `pump reservoir` console registration in `firmware/main/diag_console.cpp` (compile-time absence on flag=0; `pump status` reports exactly the existing pumps) +- [ ] T016 [P] [US2] Host tests for capability gating in `firmware/test_apps/host/main/test_level_sensor.cpp` or existing pump suite: whatever pump-wiring logic is host-reachable stays green on both configs; at minimum assert the board-header contract compiles both ways (a compile-time test: rev1 path references the pin, shared code does not) — plus regression: full existing pump suite untouched (SC-004) + +## Phase 5: User Story 3 — Pump power telemetry on rev2 (P3) + +- [ ] T017 [US3] Implement `Ina226Sensor` (pure) in `firmware/components/sensors/include/sensors/Ina226Sensor.h` + `src/Ina226Sensor.cpp`: identity check (0xFE==0x5449, 0xFF==0x2260), config + calibration writes (verify register field values against TI datasheet SBOS547 — flagged in research R7), CAL = 0.00512/(CurrentLSB×Rshunt) from constructor shunt param, scaling (busV ×1.25 mV; current signed ×0.5 mA; power ×25×CurrentLSB), NaN placeholders, errors 0/1/2, lazy re-init, uninitialize-on-bus-error. CMake (analyze finding I1, satisfies FR-011 literally): `Ina226Sensor.cpp` builds on linux always (host tests) and on target only when `CONFIG_BOARD_REV2` — the rev1 binary contains no INA226 code +- [ ] T018 [P] [US3] Host tests in `firmware/test_apps/host/main/test_ina226.cpp` (suite `run_ina226_tests()`): scaling vectors hand-computed from datasheet formulas incl. the 5 mΩ/0.5 mA operating point (CAL=2048) and a negative-current case (sign preserved); identity mismatch → error 1; absent device → error 1; mid-read bus error → error 2 + last-good + recovery re-probe; config/cal write bytes asserted big-endian in order +- [ ] T019 [P] [US3] Implement `LockedPowerSensor` in `firmware/components/sensors/include/sensors/LockedPowerSensor.h` +- [ ] T020 [US3] Kconfig `WS_INA226_SHUNT_MILLIOHM` (int, default 5, range 1–1000) in `firmware/main/Kconfig.projbuild`; `power` console command (`diag_console_register_power(IPowerSensor&)`) in `firmware/main/diag_console.h/.cpp` — error output distinguishes 1 vs 2; wire `Ina226Sensor` on the SHARED EspI2cBus instance in `firmware/main/app_main.cpp` under `#if BOARD_HAS_INA226` + +## Phase 6: User Story 4 — Behavior testable without hardware (P4) + +- [ ] T021 [P] [US4] Create `MockLevelSensor` in `firmware/components/sensors/include/sensors/testing/MockLevelSensor.h` with consistency helpers (`scriptValidState(present)`, `scriptInvalid()`); host tests proving all four PR-11 truth-table combinations expressible across two instances (incl. low-dry+high-wet) with coherent validity — the SC-006 consumer-style test +- [ ] T022 [P] [US4] Fail-direction truth tests in `firmware/test_apps/host/main/test_level_sensor.cpp`: disconnected-input simulation (raw pulled HIGH) → rev1 config reads "water present", rev2 config reads "water absent" — checklist line 97 pinned per board with a comment citing the pump-topology safety rationale +- [ ] T023 [US4] Register both new suites in `firmware/test_apps/host/main/CMakeLists.txt` + `test_main.cpp` (`run_level_sensor_tests`, `run_ina226_tests`) + +## Phase 7: Polish & Cross-Cutting + +- [ ] T024 [P] Update `firmware/CLAUDE.md`: directory tree, console commands (`level`, `power`, gated `pump reservoir`), new sections (level sensors: polarity/debounce/settle + capability flag rationale; INA226: shared-bus rule, Kconfig shunt, PR-14 deferral), host-test description +- [ ] T025 [P] `docs/parity-checklist.md`: §6 divergence entries (debounce, not-yet-valid gating, INA226 identity check, reservoir-pump capability flag) per contracts/interfaces.md list; prepare the line-96 recording slot for the rev1 bench measurement (do NOT fill it — Paul's HIL records it) +- [ ] T026 [P] Write HIL checklist `specs/006-level-sensors-ina226/checklists/hil.md` (rev1 rig): A level status wet/dry both marks + record measured polarity in parity checklist line 96, B chatter → single transition, C fail direction (disconnect), D pump regression (`pump reservoir` + boot force-off + `env`/`soil` intact), E INA226 note (host-covered, hardware at PR-14) +- [ ] T027 Full verification per quickstart.md: host suite exit 0, rev1+rev2 green from clean checkout, `dependencies.lock` unchanged; capture outputs for the CP3 dossier + +## Dependencies & Execution Order + +- Phase 1 (T001, external gate on #11) → Phase 2 → user stories. +- Within Phase 2: T002/T003/T007 parallel; T004 → T005/T006. +- US1 (T008–T013) is the MVP; T008 blocks T009/T011/T012/T013. +- US2 (T014–T016) independent of US1 code but shares app_main/diag_console files — run after US1's wiring to avoid churn. +- US3 (T017–T020) needs Phase 2's bus extension (T004–T006); parallel with US1/US2 otherwise. +- US4 (T021–T023) needs T008 (fail-direction tests) and T017 (suite registration together in T023). +- Polish last; T027 = main-session verification. + +## Implementation Strategy + +MVP = Phases 1–3 (level sensors visible on the rig console). US2 flips the +capability flag with compile-time enforcement (biggest regression risk — isolated +commits). US3 rides the bus extension. Mission split for the implementer (process +lesson): write-only missions, verification in the main session; suggested split +Mission 1 = Phases 2–3, Mission 2 = Phases 4–7 (T027 in main session). From e668a7a4b77bb8b993d28112895e49840f394d27 Mon Sep 17 00:00:00 2001 From: Cryptotomte Date: Fri, 3 Jul 2026 06:33:29 +0200 Subject: [PATCH 2/4] feat(sensors): level sensors with debounce/settle gating and I2C 16-bit seam ILevelSensor/IPowerSensor/IDigitalInput interfaces; pure DebouncedLevelSensor (settle -> warm-up -> tracking state machine, 300 ms stability window, board-owned polarity per FW-5, FW-3 settle gating via notifyPowerOn); GpioLevelSensor raw input (pull-ups, R4); LockedLevelSensor; level console command distinguishing not-yet-valid; board.h capability table incl. BOARD_HAS_RESERVOIR_PUMP (rev2 pin removed - rev2 target red until the T014 app_main gating lands next commit); II2cBus writeRegister16/readRegister16 extension through EspI2cBus and MockI2cBus. Host suite 139/0, rev1 green (T002-T013, T023 pulled forward). Co-Authored-By: Claude Fable 5 --- firmware/CLAUDE.md | 2 +- .../components/board/include/board/board.h | 115 ++++- .../include/interfaces/IDigitalInput.h | 36 ++ .../interfaces/include/interfaces/II2cBus.h | 53 ++- .../include/interfaces/ILevelSensor.h | 103 +++++ .../include/interfaces/IPowerSensor.h | 107 +++++ firmware/components/sensors/CMakeLists.txt | 36 +- .../include/sensors/DebouncedLevelSensor.h | 110 +++++ .../sensors/include/sensors/EspI2cBus.h | 2 + .../sensors/include/sensors/GpioLevelSensor.h | 64 +++ .../include/sensors/LockedLevelSensor.h | 92 ++++ .../include/sensors/testing/MockI2cBus.h | 41 +- .../sensors/src/DebouncedLevelSensor.cpp | 107 +++++ firmware/components/sensors/src/EspI2cBus.cpp | 30 ++ .../sensors/src/GpioLevelSensor.cpp | 50 +++ firmware/main/app_main.cpp | 45 +- firmware/main/diag_console.cpp | 71 +++ firmware/main/diag_console.h | 15 + firmware/test_apps/host/main/CMakeLists.txt | 2 + firmware/test_apps/host/main/test_ina226.cpp | 176 ++++++++ .../test_apps/host/main/test_level_sensor.cpp | 424 ++++++++++++++++++ firmware/test_apps/host/main/test_main.cpp | 4 + specs/006-level-sensors-ina226/tasks.md | 35 +- 23 files changed, 1662 insertions(+), 58 deletions(-) create mode 100644 firmware/components/interfaces/include/interfaces/IDigitalInput.h create mode 100644 firmware/components/interfaces/include/interfaces/ILevelSensor.h create mode 100644 firmware/components/interfaces/include/interfaces/IPowerSensor.h create mode 100644 firmware/components/sensors/include/sensors/DebouncedLevelSensor.h create mode 100644 firmware/components/sensors/include/sensors/GpioLevelSensor.h create mode 100644 firmware/components/sensors/include/sensors/LockedLevelSensor.h create mode 100644 firmware/components/sensors/src/DebouncedLevelSensor.cpp create mode 100644 firmware/components/sensors/src/GpioLevelSensor.cpp create mode 100644 firmware/test_apps/host/main/test_ina226.cpp create mode 100644 firmware/test_apps/host/main/test_level_sensor.cpp diff --git a/firmware/CLAUDE.md b/firmware/CLAUDE.md index f8c0ce4..ae6270f 100644 --- a/firmware/CLAUDE.md +++ b/firmware/CLAUDE.md @@ -208,7 +208,7 @@ Two board revisions exist, selected via Kconfig (`main/Kconfig.projbuild`): Rev2 pins are provisional until hardware sync 1 (`TODO(SYNC1)` markers). All pins and polarity/feature flags come from `board/board.h` -(`BOARD_PIN_*`, `BOARD_HAS_RS485_DE`, `BOARD_LEVEL_SENSOR_ACTIVE_LOW`, +(`BOARD_PIN_*`, `BOARD_HAS_RS485_DE`, `BOARD_LEVEL_ACTIVE_LOW`, `BOARD_HAS_INA226`, `BOARD_NAME`). Never hard-code GPIO numbers elsewhere. Board-conditional code uses `#if CONFIG_BOARD_REV2` / `#if BOARD_HAS_INA226`. diff --git a/firmware/components/board/include/board/board.h b/firmware/components/board/include/board/board.h index d60bcd4..5706489 100644 --- a/firmware/components/board/include/board/board.h +++ b/firmware/components/board/include/board/board.h @@ -43,18 +43,30 @@ * docs/parity-checklist.md §5). */ #define BOARD_RS485_UART_PORT 2 -/* Pumps (MOSFET gates, active high) */ +/* Pumps (MOSFET gates, active high). + * Rev 1 has both pumps (plant + reservoir fill) — two-pump topology. */ #define BOARD_PIN_MAIN_PUMP 26 +#define BOARD_HAS_RESERVOIR_PUMP 1 #define BOARD_PIN_RESERVOIR_PUMP 27 -/* Reservoir level sensors (XKC-Y26). - * Rev 1: sensor OUT routed non-inverting through TXS0108E. - * XKC-Y26 OUT is active HIGH (water present = HIGH), so GPIO is active HIGH. */ +/* Reservoir level sensors (XKC-Y26), low/high mark on 32/33 with internal + * pull-ups (parity: src/main.cpp:37-38, 231-233; docs/parity-checklist.md + * line 95). + * Rev 1: sensor OUT routed non-inverting through TXS0108E. XKC-Y26 OUT is + * active HIGH (water present = HIGH), so the GPIO is active HIGH (FW-5). + * Debounce: stability window before a reported state change (deliberate + * divergence from legacy's bare reads — docs/parity-checklist.md §6); a + * hardware property of the sensor class, hence a board macro, not Kconfig. + * Settle: 0 ms — the rev1 sensor rail is permanently on (FW-3 applies to + * rev2's switched rail only); debounce warm-up subsumes settle here. */ #define BOARD_PIN_LEVEL_LOW 32 #define BOARD_PIN_LEVEL_HIGH 33 -#define BOARD_LEVEL_SENSOR_ACTIVE_LOW 0 +#define BOARD_LEVEL_ACTIVE_LOW 0 +#define BOARD_LEVEL_DEBOUNCE_MS 300 +#define BOARD_LEVEL_SETTLE_MS 0 -/* Pump current monitoring */ +/* Pump current monitoring: none on rev1 (BOARD_INA226_ADDR is deliberately + * NOT defined — unguarded references fail the build, RS485-DE pattern). */ #define BOARD_HAS_INA226 0 /* Status LED */ @@ -91,19 +103,38 @@ * of the provisional rev2 pin map — no TODO(SYNC1). */ #define BOARD_RS485_UART_PORT 2 -/* Pumps (MOSFET gates, active high) */ +/* Pumps (MOSFET gates, active high). + * Rev 2 is a SINGLE-PUMP node (master PRD FR4, final decision 2026-06-10): + * only the plant pump exists. BOARD_PIN_RESERVOIR_PUMP is deliberately NOT + * defined when BOARD_HAS_RESERVOIR_PUMP is 0: any reference that is not + * guarded by #if BOARD_HAS_RESERVOIR_PUMP becomes a compile error instead + * of driving a phantom GPIO (same enforcement pattern as + * BOARD_PIN_RS485_DE below). */ #define BOARD_PIN_MAIN_PUMP 26 // TODO(SYNC1): final rev2 pin map frozen at hardware sync 1 -#define BOARD_PIN_RESERVOIR_PUMP 27 // TODO(SYNC1): final rev2 pin map frozen at hardware sync 1 - -/* Reservoir level sensors (XKC-Y26). - * Rev 2: sensor OUT routed through a 2N7002 inverter, so GPIO is active LOW - * (water present = LOW). See PRD FR5. */ +#define BOARD_HAS_RESERVOIR_PUMP 0 + +/* Reservoir level sensors (XKC-Y26), low/high mark with internal pull-ups + * (redundant-but-harmless on top of the external 10 kΩ — rev2 design notes + * §6.3, research.md R4). + * Rev 2: sensor OUT routed through a 2N7002 inverter, so the GPIO is + * active LOW (water present = LOW) — FW-5. See PRD FR5. + * Settle: the XKC-Y26 needs ≥500 ms after its rail powers on before the + * output is trustworthy (FW-3); rail control itself arrives in PR-14 — + * this feature arms the gate once at boot. */ #define BOARD_PIN_LEVEL_LOW 32 // TODO(SYNC1): final rev2 pin map frozen at hardware sync 1 #define BOARD_PIN_LEVEL_HIGH 33 // TODO(SYNC1): final rev2 pin map frozen at hardware sync 1 -#define BOARD_LEVEL_SENSOR_ACTIVE_LOW 1 - -/* Pump current monitoring (one INA226 per pump on the I2C bus) */ -#define BOARD_HAS_INA226 1 // TODO(SYNC1): final rev2 pin map frozen at hardware sync 1 +#define BOARD_LEVEL_ACTIVE_LOW 1 +#define BOARD_LEVEL_DEBOUNCE_MS 300 +#define BOARD_LEVEL_SETTLE_MS 500 + +/* Pump current monitoring (INA226 on the shared I2C bus). + * Rev 2 I2C address map (design notes §5.2.2): + * 0x40 INA226 pump monitor (A0 = A1 = GND) + * 0x41 reserved — solar-input INA226 footprint, DNP + * 0x76 / 0x77 BME280 + * The ALERT pin is not connected — no Mask/Enable/Alert register use. */ +#define BOARD_HAS_INA226 1 +#define BOARD_INA226_ADDR 0x40 /* Status LED */ #define BOARD_PIN_STATUS_LED 2 // TODO(SYNC1): final rev2 pin map frozen at hardware sync 1 @@ -121,10 +152,15 @@ * A wrong or inconsistent pin table must fail the build, not the rig. * ------------------------------------------------------------------------ */ -/* Pin distinctness within each function group */ +/* Pin distinctness within each function group. Checks that reference the + * reservoir pump pin are guarded: on single-pump boards the pin does not + * exist (BOARD_HAS_RESERVOIR_PUMP == 0), and an unguarded reference must + * stay a compile error — never be papered over here. */ +#if BOARD_HAS_RESERVOIR_PUMP #if BOARD_PIN_MAIN_PUMP == BOARD_PIN_RESERVOIR_PUMP #error "Board sanity: BOARD_PIN_MAIN_PUMP and BOARD_PIN_RESERVOIR_PUMP must differ" #endif +#endif #if BOARD_PIN_LEVEL_LOW == BOARD_PIN_LEVEL_HIGH #error "Board sanity: BOARD_PIN_LEVEL_LOW and BOARD_PIN_LEVEL_HIGH must differ" #endif @@ -134,11 +170,35 @@ /* Pumps must not share a pin with the level sensors */ #if (BOARD_PIN_MAIN_PUMP == BOARD_PIN_LEVEL_LOW) || \ - (BOARD_PIN_MAIN_PUMP == BOARD_PIN_LEVEL_HIGH) || \ - (BOARD_PIN_RESERVOIR_PUMP == BOARD_PIN_LEVEL_LOW) || \ + (BOARD_PIN_MAIN_PUMP == BOARD_PIN_LEVEL_HIGH) +#error "Board sanity: pump pins collide with level sensor pins" +#endif +#if BOARD_HAS_RESERVOIR_PUMP +#if (BOARD_PIN_RESERVOIR_PUMP == BOARD_PIN_LEVEL_LOW) || \ (BOARD_PIN_RESERVOIR_PUMP == BOARD_PIN_LEVEL_HIGH) #error "Board sanity: pump pins collide with level sensor pins" #endif +#endif + +/* Level sensors must not share a pin with the I2C bus or the RS485 UART */ +#if (BOARD_PIN_LEVEL_LOW == BOARD_PIN_I2C_SDA) || \ + (BOARD_PIN_LEVEL_LOW == BOARD_PIN_I2C_SCL) || \ + (BOARD_PIN_LEVEL_HIGH == BOARD_PIN_I2C_SDA) || \ + (BOARD_PIN_LEVEL_HIGH == BOARD_PIN_I2C_SCL) +#error "Board sanity: level sensor pins collide with I2C pins" +#endif +#if (BOARD_PIN_LEVEL_LOW == BOARD_PIN_RS485_TX) || \ + (BOARD_PIN_LEVEL_LOW == BOARD_PIN_RS485_RX) || \ + (BOARD_PIN_LEVEL_HIGH == BOARD_PIN_RS485_TX) || \ + (BOARD_PIN_LEVEL_HIGH == BOARD_PIN_RS485_RX) +#error "Board sanity: level sensor pins collide with RS485 UART pins" +#endif +#if BOARD_HAS_RS485_DE +#if (BOARD_PIN_LEVEL_LOW == BOARD_PIN_RS485_DE) || \ + (BOARD_PIN_LEVEL_HIGH == BOARD_PIN_RS485_DE) +#error "Board sanity: level sensor pins collide with the RS485 DE pin" +#endif +#endif /* Feature flag consistency: BOARD_HAS_RS485_DE == 1 iff the DE pin exists */ #if BOARD_HAS_RS485_DE && !defined(BOARD_PIN_RS485_DE) @@ -148,4 +208,21 @@ #error "Board sanity: BOARD_PIN_RS485_DE is defined but BOARD_HAS_RS485_DE is 0" #endif +/* Feature flag consistency: BOARD_HAS_RESERVOIR_PUMP == 1 iff the pump pin + * exists (single-pump decision, master PRD FR4 — same pattern as RS485 DE) */ +#if BOARD_HAS_RESERVOIR_PUMP && !defined(BOARD_PIN_RESERVOIR_PUMP) +#error "Board sanity: BOARD_HAS_RESERVOIR_PUMP is 1 but BOARD_PIN_RESERVOIR_PUMP is not defined" +#endif +#if !BOARD_HAS_RESERVOIR_PUMP && defined(BOARD_PIN_RESERVOIR_PUMP) +#error "Board sanity: BOARD_PIN_RESERVOIR_PUMP is defined but BOARD_HAS_RESERVOIR_PUMP is 0" +#endif + +/* Feature flag consistency: BOARD_HAS_INA226 == 1 iff the address exists */ +#if BOARD_HAS_INA226 && !defined(BOARD_INA226_ADDR) +#error "Board sanity: BOARD_HAS_INA226 is 1 but BOARD_INA226_ADDR is not defined" +#endif +#if !BOARD_HAS_INA226 && defined(BOARD_INA226_ADDR) +#error "Board sanity: BOARD_INA226_ADDR is defined but BOARD_HAS_INA226 is 0" +#endif + #endif /* WATERINGSYSTEM_BOARD_BOARD_H */ diff --git a/firmware/components/interfaces/include/interfaces/IDigitalInput.h b/firmware/components/interfaces/include/interfaces/IDigitalInput.h new file mode 100644 index 0000000..424aaec --- /dev/null +++ b/firmware/components/interfaces/include/interfaces/IDigitalInput.h @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file IDigitalInput.h + * @brief Injected raw digital-input source (single-pin read seam). + * + * The host-test seam for GPIO-input drivers (feature 006, research.md R1): + * DebouncedLevelSensor holds all settle/debounce/polarity policy above this + * interface and is host-tested against a scripted implementation; + * GpioLevelSensor is the only hardware-touching implementation (raw + * gpio_get_level, no logic). A tiny interface was chosen over std::function + * to match the codebase's interface-injection style (ITimeProvider, + * IModbusClient, II2cBus) and to avoid std::function's potential allocation. + * + * Part of the header-only `interfaces` component: no IDF includes allowed. + */ + +#ifndef WATERINGSYSTEM_INTERFACES_IDIGITALINPUT_H +#define WATERINGSYSTEM_INTERFACES_IDIGITALINPUT_H + +/** + * @brief One digital input, read on demand. + */ +class IDigitalInput { +public: + virtual ~IDigitalInput() = default; + + /** + * @brief Current raw electrical level: true = HIGH, false = LOW. + * + * No debounce, no polarity mapping — policy belongs to the consumer. + */ + virtual bool read() = 0; +}; + +#endif /* WATERINGSYSTEM_INTERFACES_IDIGITALINPUT_H */ diff --git a/firmware/components/interfaces/include/interfaces/II2cBus.h b/firmware/components/interfaces/include/interfaces/II2cBus.h index c8f7b0e..528803d 100644 --- a/firmware/components/interfaces/include/interfaces/II2cBus.h +++ b/firmware/components/interfaces/include/interfaces/II2cBus.h @@ -10,8 +10,13 @@ * Normative contract: specs/005-bme280-i2c/contracts/interfaces.md. * * This interface carries no BME280 knowledge — PR-05's INA226 driver reuses - * it on the same bus instance (INA226 uses 16-bit register values; PR-05 - * may extend the interface or compose two 8-bit operations, its call). + * it on the same bus instance. PR-05 exercised the extension option its + * contract sanctioned ("may extend the interface or compose two 8-bit + * operations") and added the 16-bit register operations: writeRegister16() + * as a new virtual (the INA226's pointer + two data bytes must land in ONE + * transaction — not composable from single-byte writes) and readRegister16() + * as a non-virtual convenience over readRegisters() (feature 006, + * research.md R6; specs/006-level-sensors-ina226/contracts/interfaces.md). * * Part of the header-only `interfaces` component: no IDF includes allowed. */ @@ -74,6 +79,50 @@ class II2cBus { */ virtual bool writeRegister(uint8_t address7, uint8_t reg, uint8_t value) = 0; + + /** + * @brief Write one 16-bit register value, BIG-ENDIAN, in a single + * transaction (register pointer + MSB + LSB). + * + * Required by the INA226 configuration/calibration writes (feature + * 006): the device latches a 16-bit value per write transaction, so + * this is NOT composable from two writeRegister() calls. Same error + * semantics as writeRegister(): false on NACK/bus error/timeout, no + * retries at this layer. + * + * @param address7 7-bit device address. + * @param reg Register address. + * @param value Value to write; transmitted MSB first (big-endian). + * @return true if the device acknowledged the write. + */ + virtual bool writeRegister16(uint8_t address7, uint8_t reg, + uint16_t value) = 0; + + /** + * @brief Read one 16-bit register value, BIG-ENDIAN decoded. + * + * Non-virtual convenience implemented on the interface over + * readRegisters(..., 2) — a 16-bit read is already expressible as a + * 2-byte burst, so implementations provide nothing extra (feature 006, + * research.md R6). Exists for symmetry with writeRegister16() and + * call-site clarity in the INA226 driver. + * + * @param address7 7-bit device address. + * @param reg Register address. + * @param out Decoded value (MSB-first); defined only when the call + * returns true. + * @return true if both bytes were read. + */ + bool readRegister16(uint8_t address7, uint8_t reg, uint16_t& out) + { + uint8_t buf[2] = {0, 0}; + if (!readRegisters(address7, reg, buf, sizeof(buf))) { + return false; + } + out = static_cast((static_cast(buf[0]) << 8) | + buf[1]); + return true; + } }; #endif /* WATERINGSYSTEM_INTERFACES_II2CBUS_H */ diff --git a/firmware/components/interfaces/include/interfaces/ILevelSensor.h b/firmware/components/interfaces/include/interfaces/ILevelSensor.h new file mode 100644 index 0000000..5eb09a4 --- /dev/null +++ b/firmware/components/interfaces/include/interfaces/ILevelSensor.h @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file ILevelSensor.h + * @brief Interface for one XKC-Y26 reservoir water-level mark (low or high). + * + * One instance per level mark (research.md R1): the low mark and the high + * mark are two independent ILevelSensor objects, so PR-11's watering + * controller composes its truth table (incl. the invalid combination) from + * two sensors instead of an aggregate baked into this layer. Normative + * contract: specs/006-level-sensors-ina226/contracts/interfaces.md; the + * settle/warm-up/tracking state machine: + * specs/006-level-sensors-ina226/data-model.md. + * + * Deliberate divergences from the frozen legacy firmware + * (docs/parity-checklist.md §6): stability-window debounce (legacy reads + * bare pins every loop pass) and an explicit not-yet-valid state during + * settle gating and debounce warm-up (legacy has no validity concept). + * + * Validity contract: isWaterPresent() is meaningful ONLY while isValid() + * returns true. Consumers gate on validity — PR-11's truth table treats an + * invalid sensor as "do not act", never as "water absent". + * + * Concurrency: implementations are unsynchronized by design; cross-task + * consumers (main-loop update() + console REPL, controller in PR-11) wrap + * them in the LockedLevelSensor decorator, same pattern as the other + * Locked* wrappers. + * + * Part of the header-only `interfaces` component: no IDF includes allowed. + */ + +#ifndef WATERINGSYSTEM_INTERFACES_ILEVELSENSOR_H +#define WATERINGSYSTEM_INTERFACES_ILEVELSENSOR_H + +/** + * @brief Polled, debounced, polarity-absorbed water-level state with + * explicit validity. + * + * Fail direction (pinned by host tests, docs/parity-checklist.md line 97): + * a disconnected input is pulled HIGH by the board's pull-up, so rev1 + * (active HIGH) reads "water present" — the fill pump stays off — while + * rev2 (active LOW, 2N7002 inverter) reads "water absent" — the drawing + * node does not pump. Both directions fail safe for their pump topology. + */ +class ILevelSensor { +public: + virtual ~ILevelSensor() = default; + + /** + * @brief Sample the raw input once and advance the settle/debounce + * state machine. + * + * Called by the owner at its polling cadence (the 10 Hz main loop). + * No update ⇒ no state change: validity and the reported state are + * functions of the update stream, never of wall-clock reads alone — + * time passing without update() calls changes nothing. + */ + virtual void update() = 0; + + /** + * @brief Whether isWaterPresent() currently carries meaning. + * + * False during settle gating (after construction or notifyPowerOn(), + * FW-3) and during debounce warm-up (until the raw input has held one + * value for a full stability window). Consumers MUST gate on this — + * "not yet valid" is a distinct state, never to be conflated with + * wet or dry (SC-005/FR-012). + */ + virtual bool isValid() = 0; + + /** + * @brief Logical water state: true = water present at this mark. + * + * Polarity is fully absorbed here via board configuration (FW-5: + * rev1 GPIO active HIGH, rev2 active LOW) — no consumer ever sees a + * raw-polarity value except through rawState(). Debounced: the value + * changes only after the raw input has been stable in the new state + * for the board's debounce window; any flip restarts the window. + * Meaningful ONLY while isValid() returns true (returns false before + * the first stable window, by definition without meaning). + */ + virtual bool isWaterPresent() = 0; + + /** + * @brief Raw, undebounced pin level from the most recent update() + * (true = electrically HIGH). Diagnostics only — consumers use + * isWaterPresent(). + */ + virtual bool rawState() = 0; + + /** + * @brief Re-arm settle gating after a sensor-rail power-on (FW-3). + * + * From any state the sensor returns to settling: readings are invalid + * again until the settle window (rev2 ≥500 ms; rev1 0 ms) has elapsed + * AND a fresh debounce warm-up completes. Rail *control* itself is + * PR-14 (CP1 decision A) — in this feature app_main calls this once at + * boot (the rail is on by default at power-up on rev2). + */ + virtual void notifyPowerOn() = 0; +}; + +#endif /* WATERINGSYSTEM_INTERFACES_ILEVELSENSOR_H */ diff --git a/firmware/components/interfaces/include/interfaces/IPowerSensor.h b/firmware/components/interfaces/include/interfaces/IPowerSensor.h new file mode 100644 index 0000000..9e73bc1 --- /dev/null +++ b/firmware/components/interfaces/include/interfaces/IPowerSensor.h @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file IPowerSensor.h + * @brief Interface for the INA226 pump power monitor (bus V / current / power). + * + * Raw telemetry only — no protection or alarm logic rides on these values + * (out of scope by the master PRD; threshold logic is PR-14+). Normative + * contract: specs/006-level-sensors-ina226/contracts/interfaces.md; + * registers and scaling: specs/006-level-sensors-ina226/data-model.md. + * + * Validity contract: identical to the established sensor family + * (IEnvironmentalSensor/ISoilSensor). A false return from read() means the + * data is invalid; the getters keep returning the last-good values (NaN + * before the first success), so consumers MUST gate on the read result / + * getLastError() — never on value plausibility. + * + * Concurrency: implementations are unsynchronized by design; cross-task + * consumers (console REPL now; web PR-09, controller PR-11) wrap them in + * the LockedPowerSensor decorator. + * + * Part of the header-only `interfaces` component: no IDF includes allowed. + */ + +#ifndef WATERINGSYSTEM_INTERFACES_IPOWERSENSOR_H +#define WATERINGSYSTEM_INTERFACES_IPOWERSENSOR_H + +/** + * @brief Power sensor: atomic V/I/P snapshots with lazy recovery. + * + * Error codes reported by getLastError() (family convention, + * specs/006-level-sensors-ina226/data-model.md): + * + * 0 OK — last operation succeeded + * 1 sensor not found — no device ACK at the configured address, or a + * responding device failed the identity check (manufacturer/die ID) + * 2 read/communication failure after the device identified — a bus + * error during a data read or a mid-initialization failure + * (config/calibration write after identification) + */ +class IPowerSensor { +public: + virtual ~IPowerSensor() = default; + + /** + * @brief Find and configure the sensor. + * + * Verifies the device identity, then writes the configuration and + * calibration registers. Returns false with error 1 when no device is + * found (or the identity check fails), and false with error 2 when a + * device identified but a subsequent register write failed (the driver + * stays uninitialized, so the next attempt re-probes from scratch). + * Idempotent, and lazy-capable: read()/isAvailable() attempt + * initialization themselves when it has not happened yet — calling + * this first is recommended, not required (family convention). + */ + virtual bool initialize() = 0; + + /** + * @brief Take ONE snapshot: bus voltage, current and power refreshed + * together. + * + * Atomic: either all three getters are refreshed, or the call fails + * (error 1 when the lazy (re-)initialization finds no sensor, error 2 + * on a bus error during the data reads) and the last-good values + * remain untouched. A bus error marks the driver uninitialized, so + * the next call re-probes the identity (recovery). No retries — + * recovery comes from the caller's poll cadence. + * + * @return true if a fully valid reading was taken. + */ + virtual bool read() = 0; + + /** + * @brief Probe sensor presence with a REAL identity read. + * + * Every call performs an actual bus transaction — never cached state. + * The probe itself never touches getLastError(); when it triggers a + * lazy (re-)initialization, that initialize() owns its own error + * reporting (family convention). Consumers must not pair + * isAvailable() with getLastError(). + */ + virtual bool isAvailable() = 0; + + /** + * @brief Error code of the most recent initialize()/read() (0 = OK; + * table in the class comment). The isAvailable() probe never touches + * this code. + */ + virtual int getLastError() = 0; + + // Values from the most recent successful read(). NaN until the first + // successful read() (self-announcing placeholders), and after a failed + // read() they still hold the previous good reading — consumers gate on + // the read() result, never on the values. + + /// Bus voltage in V. + virtual float getBusVoltage() = 0; + + /// Current in A — SIGNED (reverse current stays negative, never wrapped). + virtual float getCurrent() = 0; + + /// Power in W. + virtual float getPower() = 0; +}; + +#endif /* WATERINGSYSTEM_INTERFACES_IPOWERSENSOR_H */ diff --git a/firmware/components/sensors/CMakeLists.txt b/firmware/components/sensors/CMakeLists.txt index 2cd1d78..7dd336c 100644 --- a/firmware/components/sensors/CMakeLists.txt +++ b/firmware/components/sensors/CMakeLists.txt @@ -1,30 +1,36 @@ -# sensors — sensor driver layer (RS485 Modbus soil sensor + I2C BME280). +# sensors — sensor driver layer (RS485 Modbus soil sensor + I2C BME280 + +# reservoir level sensors). # -# ModbusSoilSensor.cpp and Bme280Sensor.cpp are pure C++ (decode/validation/ -# calibration resp. probe/compensation logic) and build on every target, -# including the linux preview target used by the host test suite. +# ModbusSoilSensor.cpp, Bme280Sensor.cpp and DebouncedLevelSensor.cpp are +# pure C++ (decode/validation/calibration resp. probe/compensation resp. +# settle/debounce/polarity logic) and build on every target, including the +# linux preview target used by the host test suite. # EspModbusClient.cpp (esp-modbus master + UART RS485 half-duplex + RX -# pull-up) and EspI2cBus.cpp (i2c_master bus owner) are the only hardware -# touchpoints and are excluded — together with their driver/esp-modbus -# dependencies — when building for linux (research.md R7/005 R6, same -# mechanism as storage/actuators). +# pull-up), EspI2cBus.cpp (i2c_master bus owner) and GpioLevelSensor.cpp +# (raw GPIO level input) are the only hardware touchpoints and are +# excluded — together with their driver/esp-modbus dependencies — when +# building for linux (research.md R7/005 R6/006 R1, same mechanism as +# storage/actuators). if(${IDF_TARGET} STREQUAL "linux") idf_component_register( SRCS "src/ModbusSoilSensor.cpp" "src/Bme280Sensor.cpp" + "src/DebouncedLevelSensor.cpp" INCLUDE_DIRS "include" REQUIRES interfaces board ) else() - # Target build only: the esp-modbus client and the I2C bus. esp-modbus - # is a managed component (pinned in this component's idf_component.yml, - # rule-gated to non-linux targets; registered under its namespaced - # component name). PRIV: esp-modbus, driver and i2c_master headers - # appear only in src/EspModbusClient.cpp / src/EspI2cBus.cpp / private - # code, never in this component's public headers (same rule as - # storage's littlefs). + # Target build only: the esp-modbus client, the I2C bus and the GPIO + # level input. esp-modbus is a managed component (pinned in this + # component's idf_component.yml, rule-gated to non-linux targets; + # registered under its namespaced component name). PRIV: esp-modbus, + # driver and i2c_master headers appear only in src/EspModbusClient.cpp + # / src/EspI2cBus.cpp / src/GpioLevelSensor.cpp / private code, never + # in this component's public headers (same rule as storage's littlefs). idf_component_register( SRCS "src/ModbusSoilSensor.cpp" "src/Bme280Sensor.cpp" + "src/DebouncedLevelSensor.cpp" "src/EspModbusClient.cpp" "src/EspI2cBus.cpp" + "src/GpioLevelSensor.cpp" INCLUDE_DIRS "include" REQUIRES interfaces board PRIV_REQUIRES espressif__esp-modbus esp_driver_uart esp_driver_gpio diff --git a/firmware/components/sensors/include/sensors/DebouncedLevelSensor.h b/firmware/components/sensors/include/sensors/DebouncedLevelSensor.h new file mode 100644 index 0000000..a25f583 --- /dev/null +++ b/firmware/components/sensors/include/sensors/DebouncedLevelSensor.h @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file DebouncedLevelSensor.h + * @brief Pure C++ level-sensor logic: settle gating, debounce, polarity. + * + * Implements ILevelSensor over an injected raw-input source + * (IDigitalInput — GpioLevelSensor on target, a scripted input in host + * tests) and an injected ITimeProvider (never esp_timer directly — the + * WaterPump time-injection pattern). ALL policy lives here: the + * SETTLING → WARMUP → TRACKING state machine + * (specs/006-level-sensors-ina226/data-model.md), the stability-window + * debounce (any raw flip restarts the window — research.md R2) and the + * board-owned polarity mapping (FW-5; the active-low flag is passed in + * from board.h at the wiring site, keeping this class board-agnostic). + * It contains NO hardware access, so it is compiled and unit-tested on + * the IDF linux preview target (constitution II). + * + * Concurrency: unsynchronized by design (host-testable); cross-task + * consumers (main-loop update() + console REPL) wrap it in + * LockedLevelSensor and access it only through the wrapper. + */ + +#ifndef WATERINGSYSTEM_SENSORS_DEBOUNCEDLEVELSENSOR_H +#define WATERINGSYSTEM_SENSORS_DEBOUNCEDLEVELSENSOR_H + +#include + +#include "interfaces/IDigitalInput.h" +#include "interfaces/ILevelSensor.h" +#include "interfaces/ITimeProvider.h" + +/** + * @brief ILevelSensor with settle gating, warm-up and debounce over a raw + * digital input. + * + * State machine (data-model.md): + * + * CONSTRUCT / notifyPowerOn() ──▶ SETTLING + * SETTLING ──▶ WARMUP when settleMs has elapsed (settleMs = 0: on + * the first update()) + * WARMUP ──▶ TRACKING when the raw input has held one value for a + * full debounce window (isValid() becomes true) + * TRACKING: a raw flip opens a stability window; the reported state + * changes only once the new value has held for debounceMs; + * every flip restarts the window. + * + * All time is measured from the update() stream: the settle window starts + * at the FIRST update() after construction/notifyPowerOn() (never at the + * event itself — "no update ⇒ no state change", ILevelSensor contract; the + * slight lateness is in the safe direction). A window of N ms is complete + * on the first update() where at least N ms have elapsed since it opened. + */ +class DebouncedLevelSensor : public ILevelSensor { +public: + /** + * @brief Construct the sensor over an injected input and clock. + * + * Starts in SETTLING (gated — even with settleMs = 0 a full debounce + * warm-up must complete before isValid()). + * + * @param input Raw input source; must outlive this object. + * @param time Monotonic clock; must outlive this object. + * @param activeLow Board polarity (FW-5): true when a LOW pin level + * means water present (rev2's 2N7002 inverter); pass + * BOARD_LEVEL_ACTIVE_LOW from board.h. + * @param debounceMs Stability window before a reported state change + * (BOARD_LEVEL_DEBOUNCE_MS). + * @param settleMs Invalidity window after a power-on event + * (BOARD_LEVEL_SETTLE_MS; 0 = no settle gating + * beyond the debounce warm-up). + */ + DebouncedLevelSensor(IDigitalInput& input, ITimeProvider& time, + bool activeLow, int64_t debounceMs, + int64_t settleMs); + + ~DebouncedLevelSensor() override = default; + + DebouncedLevelSensor(const DebouncedLevelSensor&) = delete; + DebouncedLevelSensor& operator=(const DebouncedLevelSensor&) = delete; + + // ILevelSensor + void update() override; + bool isValid() override; + bool isWaterPresent() override; + bool rawState() override; + void notifyPowerOn() override; + +private: + enum class State { Settling, Warmup, Tracking }; + + /// Sentinel: the settle window has not started yet (armed, waiting for + /// the first update()). + static constexpr int64_t kNotStarted = -1; + + IDigitalInput& input_; + ITimeProvider& time_; + const bool activeLow_; + const int64_t debounceMs_; + const int64_t settleMs_; + + State state_ = State::Settling; + int64_t settleStartMs_ = kNotStarted; ///< first update() after arming + bool raw_ = false; ///< last sampled pin level (rawState()) + bool candidateRaw_ = false; ///< value the stability window is timing + int64_t windowStartMs_ = 0; ///< when candidateRaw_ last changed + bool stableRaw_ = false; ///< debounced pin level (valid iff TRACKING) +}; + +#endif /* WATERINGSYSTEM_SENSORS_DEBOUNCEDLEVELSENSOR_H */ diff --git a/firmware/components/sensors/include/sensors/EspI2cBus.h b/firmware/components/sensors/include/sensors/EspI2cBus.h index 4e04d34..e5ee9cd 100644 --- a/firmware/components/sensors/include/sensors/EspI2cBus.h +++ b/firmware/components/sensors/include/sensors/EspI2cBus.h @@ -68,6 +68,8 @@ class EspI2cBus : public II2cBus { bool readRegisters(uint8_t address7, uint8_t startReg, uint8_t* buf, size_t len) override; bool writeRegister(uint8_t address7, uint8_t reg, uint8_t value) override; + bool writeRegister16(uint8_t address7, uint8_t reg, + uint16_t value) override; private: /// Enough for BME280 (one of two addresses) + PR-05's INA226 devices. diff --git a/firmware/components/sensors/include/sensors/GpioLevelSensor.h b/firmware/components/sensors/include/sensors/GpioLevelSensor.h new file mode 100644 index 0000000..084cd53 --- /dev/null +++ b/firmware/components/sensors/include/sensors/GpioLevelSensor.h @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file GpioLevelSensor.h + * @brief GPIO-backed raw input for one level-sensor pin (no logic). + * + * ESP32-ONLY: excluded from the linux-target build (see this component's + * CMakeLists.txt). This is the level-sensor hardware touchpoint — all + * settle/debounce/polarity policy lives above IDigitalInput, in + * DebouncedLevelSensor (research.md R1, same split as EspI2cBus / + * EspModbusClient). + * + * PRIV rule (this component's convention): driver/gpio.h appears only in + * the .cpp, never here — the pin is held as a plain int. + */ + +#ifndef WATERINGSYSTEM_SENSORS_GPIOLEVELSENSOR_H +#define WATERINGSYSTEM_SENSORS_GPIOLEVELSENSOR_H + +#include "interfaces/IDigitalInput.h" + +/** + * @brief IDigitalInput over one GPIO pin, input with internal pull-up. + * + * The internal pull-up is enabled on BOTH boards (research.md R4): parity + * on rev1 (legacy src/main.cpp:231-233 uses INPUT_PULLUP, + * docs/parity-checklist.md line 95), redundant-but-harmless on rev2 on top + * of the external 10 kΩ. The pull-up also pins the documented fail + * direction: a disconnected input reads HIGH (docs/parity-checklist.md + * line 97). + */ +class GpioLevelSensor : public IDigitalInput { +public: + /** + * @brief Construct the raw input on @p pin. + * + * @param pin Level-sensor GPIO (from board.h, never hard-coded). No + * hardware access here — call initialize() first. + */ + explicit GpioLevelSensor(int pin); + + ~GpioLevelSensor() override = default; + + GpioLevelSensor(const GpioLevelSensor&) = delete; + GpioLevelSensor& operator=(const GpioLevelSensor&) = delete; + + /** + * @brief Configure the pin as input with the internal pull-up enabled. + * + * @return true on success; false is logged by the implementation — + * read() then still returns the (unconfigured) pin level and + * the caller decides how loudly to complain. + */ + bool initialize(); + + // IDigitalInput + /// Raw pin level (true = HIGH). One gpio_get_level(), no logic. + bool read() override; + +private: + int pin_; +}; + +#endif /* WATERINGSYSTEM_SENSORS_GPIOLEVELSENSOR_H */ diff --git a/firmware/components/sensors/include/sensors/LockedLevelSensor.h b/firmware/components/sensors/include/sensors/LockedLevelSensor.h new file mode 100644 index 0000000..0e7ed44 --- /dev/null +++ b/firmware/components/sensors/include/sensors/LockedLevelSensor.h @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file LockedLevelSensor.h + * @brief Mutex-serializing ILevelSensor decorator (header-only). + * + * WHY THIS EXISTS: each level sensor is reached from more than one FreeRTOS + * task — the main loop polls update() at 10 Hz while the diag console REPL + * task issues `level` commands (the watering controller in PR-11 adds + * another reader). DebouncedLevelSensor itself is deliberately + * unsynchronized pure C++ (host-testable), so its plain members would race: + * update() advances the state machine member-by-member, and a concurrent + * getter could observe a half-advanced state. This decorator wraps an + * ILevelSensor and takes a mutex around every interface call, serializing + * all access (pattern of LockedEnvironmentalSensor/LockedWaterPump). + * + * USAGE RULE: once a sensor is wrapped, the underlying sensor must ONLY be + * accessed through the wrapper — every call site (boot wiring, main-loop + * update, console registration, controllers, ...) goes through the + * LockedLevelSensor, never through the wrapped object directly. + * + * SCOPE: this decorator provides PER-CALL atomicity only, not cross-call. + * An isValid()-then-isWaterPresent() sequence is NOT protected against an + * interleaving update() from another task in between — the state may + * change (or invalidate) between the two calls. Such sequences need + * higher-level coordination. + * TODO(PR-11): add a consistent-snapshot helper (one locked call returning + * validity + logical state) when the controller becomes a second periodic + * reader — same bookkeeping as the other Locked* wrappers' PR-11 notes. + * + * Pure C++ ( is available via pthread on ESP-IDF and on the linux + * preview target), so the decorator is host-testable. + */ + +#ifndef WATERINGSYSTEM_SENSORS_LOCKEDLEVELSENSOR_H +#define WATERINGSYSTEM_SENSORS_LOCKEDLEVELSENSOR_H + +#include + +#include "interfaces/ILevelSensor.h" + +/** + * @brief ILevelSensor decorator that serializes every call with a mutex. + * + * Composition, not inheritance from a concrete sensor: the base class + * stays pure (no locking) and the host tests are unchanged. The wrapped + * sensor must outlive this object. + */ +class LockedLevelSensor : public ILevelSensor { +public: + /// Wrap @p sensor; the wrapped sensor must outlive this object. + explicit LockedLevelSensor(ILevelSensor& sensor) : sensor_(sensor) {} + + LockedLevelSensor(const LockedLevelSensor&) = delete; + LockedLevelSensor& operator=(const LockedLevelSensor&) = delete; + + void update() override + { + std::lock_guard lock(mutex_); + sensor_.update(); + } + + bool isValid() override + { + std::lock_guard lock(mutex_); + return sensor_.isValid(); + } + + bool isWaterPresent() override + { + std::lock_guard lock(mutex_); + return sensor_.isWaterPresent(); + } + + bool rawState() override + { + std::lock_guard lock(mutex_); + return sensor_.rawState(); + } + + void notifyPowerOn() override + { + std::lock_guard lock(mutex_); + sensor_.notifyPowerOn(); + } + +private: + ILevelSensor& sensor_; + mutable std::mutex mutex_; +}; + +#endif /* WATERINGSYSTEM_SENSORS_LOCKEDLEVELSENSOR_H */ diff --git a/firmware/components/sensors/include/sensors/testing/MockI2cBus.h b/firmware/components/sensors/include/sensors/testing/MockI2cBus.h index 26bcc6d..3cc2b5f 100644 --- a/firmware/components/sensors/include/sensors/testing/MockI2cBus.h +++ b/firmware/components/sensors/include/sensors/testing/MockI2cBus.h @@ -10,8 +10,12 @@ * address, queued error on register read/write; corrupt data is simply * scripted wrong register bytes), and assert on the recorded call log * (config bytes written in order, burst-read shape, re-probe on recovery). - * Never compiled into target builds (only included from test code). No IDF - * includes. + * Feature 006 (INA226) extends it with 16-bit writes: writeRegister16() + * records BIG-ENDIAN into the same per-address byte map (so byte-level + * assertions stay valid) and into the call log, and shares the write + * outcome queue with the 8-bit writes (one FIFO covers a mixed-width write + * sequence). Never compiled into target builds (only included from test + * code). No IDF includes. */ #ifndef WATERINGSYSTEM_SENSORS_TESTING_MOCKI2CBUS_H @@ -41,13 +45,14 @@ class MockI2cBus : public II2cBus { public: /// One recorded bus transaction (in `calls`, chronological). struct Call { - enum class Type { Probe, Read, Write }; + enum class Type { Probe, Read, Write, Write16 }; Type type; uint8_t address; uint8_t reg; ///< startReg (reads) / register (writes); 0 for probes - size_t len; ///< byte count (reads); 1 for writes; 0 for probes - uint8_t value; ///< written value (writes); 0 otherwise + size_t len; ///< byte count (reads); 1/2 for writes; 0 for probes + uint8_t value; ///< written value (8-bit writes); 0 otherwise bool succeeded; ///< outcome reported to the caller + uint16_t value16 = 0; ///< written value (16-bit writes); 0 otherwise }; // Instrumentation (public, MockModbusClient style). @@ -87,7 +92,10 @@ class MockI2cBus : public II2cBus { */ void queueReadOutcome(bool ok) { readOutcomes_.push_back(ok); } - /// Same as queueReadOutcome() for writeRegister() calls. + /// Same as queueReadOutcome() for writeRegister()/writeRegister16() + /// calls — ONE shared FIFO covers both widths, in call order, so a + /// mixed-width write sequence (INA226 config + calibration) scripts + /// naturally. void queueWriteOutcome(bool ok) { writeOutcomes_.push_back(ok); } // -- II2cBus ------------------------------------------------------------- @@ -130,6 +138,27 @@ class MockI2cBus : public II2cBus { return true; } + bool writeRegister16(uint8_t address7, uint8_t reg, + uint16_t value) override + { + Call call{Call::Type::Write16, address7, reg, 2, 0, false, value}; + const auto device = devices_.find(address7); + if (device == devices_.end() || !nextOutcome(writeOutcomes_)) { + calls.push_back(call); + return false; + } + // BIG-ENDIAN into the per-address byte map, so byte-level + // assertions (and readRegisters()/readRegister16() round trips) + // stay valid. Indices wrap at 0xFF via the uint8_t cast — the same + // convention as setRegisters()/readRegisters(). + device->second[reg] = static_cast(value >> 8); + device->second[static_cast(reg + 1)] = + static_cast(value & 0xFF); + call.succeeded = true; + calls.push_back(call); + return true; + } + private: /// Consume the front of @p queue; an empty queue means success. static bool nextOutcome(std::vector& queue) diff --git a/firmware/components/sensors/src/DebouncedLevelSensor.cpp b/firmware/components/sensors/src/DebouncedLevelSensor.cpp new file mode 100644 index 0000000..dbc3c3b --- /dev/null +++ b/firmware/components/sensors/src/DebouncedLevelSensor.cpp @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file DebouncedLevelSensor.cpp + * @brief Settle/warm-up/debounce state machine implementation (pure C++). + * + * Builds on every target including linux (host-tested against a scripted + * IDigitalInput + FakeTimeProvider). State machine normative in + * specs/006-level-sensors-ina226/data-model.md. + */ + +#include "sensors/DebouncedLevelSensor.h" + +DebouncedLevelSensor::DebouncedLevelSensor(IDigitalInput& input, + ITimeProvider& time, + bool activeLow, int64_t debounceMs, + int64_t settleMs) + : input_(input), + time_(time), + activeLow_(activeLow), + debounceMs_(debounceMs), + settleMs_(settleMs) +{ + // Starts in SETTLING with the settle window armed but not started — + // the first update() opens it (no time read in the constructor; all + // state motion belongs to the update() stream). +} + +void DebouncedLevelSensor::update() +{ + const int64_t now = time_.nowMs(); + const bool raw = input_.read(); + raw_ = raw; // rawState() diagnostics: always the latest sample + + switch (state_) { + case State::Settling: + if (settleStartMs_ == kNotStarted) { + settleStartMs_ = now; + } + if (now - settleStartMs_ >= settleMs_) { + // Settle elapsed (immediately when settleMs_ == 0): enter + // warm-up and open the first stability window on the current + // raw value. Completion is checked on a LATER update — a full + // debounce window must pass inside WARMUP before validity. + state_ = State::Warmup; + candidateRaw_ = raw; + windowStartMs_ = now; + } + break; + + case State::Warmup: + if (raw != candidateRaw_) { + // Any flip restarts the window (research.md R2). + candidateRaw_ = raw; + windowStartMs_ = now; + } else if (now - windowStartMs_ >= debounceMs_) { + // First stable window complete: readings become valid. + stableRaw_ = candidateRaw_; + state_ = State::Tracking; + } + break; + + case State::Tracking: + if (raw != candidateRaw_) { + // Any flip restarts the window. A flip BACK to the reported + // state also lands here: the candidate rejoins stableRaw_ and + // the pending change is cancelled (no transition ever fires + // from a value that did not hold for a full window). + candidateRaw_ = raw; + windowStartMs_ = now; + } else if (candidateRaw_ != stableRaw_ && + now - windowStartMs_ >= debounceMs_) { + stableRaw_ = candidateRaw_; + } + break; + } +} + +bool DebouncedLevelSensor::isValid() +{ + return state_ == State::Tracking; +} + +bool DebouncedLevelSensor::isWaterPresent() +{ + // Polarity absorbed here (FW-5): logical = stable raw XOR active-low. + // Meaningful only while isValid() (ILevelSensor contract); before the + // first stable window stableRaw_ is the constructed false, and after a + // notifyPowerOn() it holds the last stable value — consumers gate on + // isValid(), never on this value alone. + return activeLow_ ? !stableRaw_ : stableRaw_; +} + +bool DebouncedLevelSensor::rawState() +{ + return raw_; +} + +void DebouncedLevelSensor::notifyPowerOn() +{ + // Re-arm settle gating (FW-3) from ANY state: readings are invalid + // again until the settle window AND a fresh debounce warm-up complete. + // The settle window opens at the next update() (kNotStarted), keeping + // validity a pure function of the update stream. + state_ = State::Settling; + settleStartMs_ = kNotStarted; +} diff --git a/firmware/components/sensors/src/EspI2cBus.cpp b/firmware/components/sensors/src/EspI2cBus.cpp index da4c685..9486117 100644 --- a/firmware/components/sensors/src/EspI2cBus.cpp +++ b/firmware/components/sensors/src/EspI2cBus.cpp @@ -206,3 +206,33 @@ bool EspI2cBus::writeRegister(uint8_t address7, uint8_t reg, uint8_t value) } return true; } + +bool EspI2cBus::writeRegister16(uint8_t address7, uint8_t reg, uint16_t value) +{ + i2c_master_dev_handle_t dev = + static_cast(deviceHandle(address7)); + if (dev == nullptr) { + return false; + } + // Register pointer + two data bytes BIG-ENDIAN in ONE transaction + // (II2cBus contract — the INA226 latches a 16-bit value per write, so + // this is never split into two single-byte writes). + const uint8_t payload[3] = {reg, static_cast(value >> 8), + static_cast(value & 0xFF)}; + const esp_err_t err = + i2c_master_transmit(dev, payload, sizeof(payload), kTimeoutMs); + if (err != ESP_OK) { + // Same classification as writeRegister(): expected NACK + // (ESP_ERR_INVALID_RESPONSE on IDF v6 transactions) at debug, + // timeout/unexpected errors at warning. + if (err == ESP_ERR_INVALID_RESPONSE || err == ESP_ERR_NOT_FOUND) { + ESP_LOGD(TAG, "writeRegister16(0x%02x, 0x%02x, 0x%04x): %s", + address7, reg, value, esp_err_to_name(err)); + } else { + ESP_LOGW(TAG, "writeRegister16(0x%02x, 0x%02x, 0x%04x): %s", + address7, reg, value, esp_err_to_name(err)); + } + return false; + } + return true; +} diff --git a/firmware/components/sensors/src/GpioLevelSensor.cpp b/firmware/components/sensors/src/GpioLevelSensor.cpp new file mode 100644 index 0000000..b8b2a1b --- /dev/null +++ b/firmware/components/sensors/src/GpioLevelSensor.cpp @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file GpioLevelSensor.cpp + * @brief GPIO raw-input implementation (esp32 targets only). + * + * Error handling is explicit (not ESP_ERROR_CHECK): a misconfigured level + * input must be visible in the logs regardless of the configured assertion + * level, and must never abort — level sensing is not boot-critical (the + * DebouncedLevelSensor above reports not-yet-valid/garbage-gated states, + * and PR-11's fail-safe treats invalid as "do not act"). + */ + +#include "sensors/GpioLevelSensor.h" + +#include "driver/gpio.h" +#include "esp_log.h" + +static const char *TAG = "gpiolevelsensor"; + +GpioLevelSensor::GpioLevelSensor(int pin) : pin_(pin) +{ +} + +bool GpioLevelSensor::initialize() +{ + // Input with internal pull-up on both boards (research.md R4; rev1 + // parity with legacy INPUT_PULLUP, rev2 belt-and-braces on top of the + // external 10 kΩ). The pull-up pins the fail direction: disconnected + // reads HIGH. + const gpio_config_t cfg = { + .pin_bit_mask = (1ULL << static_cast(pin_)), + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + const esp_err_t err = gpio_config(&cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "gpio_config(%d) failed: %s", pin_, + esp_err_to_name(err)); + return false; + } + return true; +} + +bool GpioLevelSensor::read() +{ + return gpio_get_level(static_cast(pin_)) != 0; +} diff --git a/firmware/main/app_main.cpp b/firmware/main/app_main.cpp index 83d4f96..f309b99 100644 --- a/firmware/main/app_main.cpp +++ b/firmware/main/app_main.cpp @@ -31,9 +31,12 @@ #include "actuators/GpioWaterPump.h" #include "actuators/LockedWaterPump.h" #include "sensors/Bme280Sensor.h" +#include "sensors/DebouncedLevelSensor.h" #include "sensors/EspI2cBus.h" #include "sensors/EspModbusClient.h" +#include "sensors/GpioLevelSensor.h" #include "sensors/LockedEnvironmentalSensor.h" +#include "sensors/LockedLevelSensor.h" #include "sensors/LockedSoilSensor.h" #include "sensors/ModbusSoilSensor.h" #include "storage/LittleFsDataStorage.h" @@ -221,11 +224,45 @@ extern "C" void app_main(void) env_sensor.getLastError()); } + // Reservoir level sensors (feature 006). Not safety-critical at boot: + // a failed GPIO init is logged and the system keeps running — the + // sensors report not-yet-valid/garbage-gated states and PR-11's + // fail-safe treats invalid as "do not act". Function-local statics + // after pumps_force_off() (boot fail-safe rule). Split per research + // R1: GpioLevelSensor is the raw pin read (input + pull-up, no logic); + // DebouncedLevelSensor holds ALL policy — polarity (FW-5), debounce + // and settle gating (FW-3) from the board macros. Wrapped in the + // mutex-serializing decorators: updated from this main loop at 10 Hz + // and read by the console REPL task (`level`), so EVERY access from + // here on goes through the wrappers. + static GpioLevelSensor level_low_input(BOARD_PIN_LEVEL_LOW); + static GpioLevelSensor level_high_input(BOARD_PIN_LEVEL_HIGH); + static DebouncedLevelSensor level_low_raw( + level_low_input, time_provider, BOARD_LEVEL_ACTIVE_LOW != 0, + BOARD_LEVEL_DEBOUNCE_MS, BOARD_LEVEL_SETTLE_MS); + static DebouncedLevelSensor level_high_raw( + level_high_input, time_provider, BOARD_LEVEL_ACTIVE_LOW != 0, + BOARD_LEVEL_DEBOUNCE_MS, BOARD_LEVEL_SETTLE_MS); + static LockedLevelSensor level_low(level_low_raw); + static LockedLevelSensor level_high(level_high_raw); + + if (!level_low_input.initialize() || !level_high_input.initialize()) { + ESP_LOGE(TAG, "level sensor GPIO init failed — level readings " + "unreliable"); + } + + // FW-3: the sensor rail is on from power-up (rail *control* arrives in + // PR-14) — arm the settle gate once at boot. On rev1 the settle window + // is 0 ms, so this only re-affirms the construction-time gating. + level_low.notifyPowerOn(); + level_high.notifyPowerOn(); + // Serial diagnostic REPL (rig testing; contracts/serial-diagnostic.md). diag_console_register_pumps(plant, reservoir); diag_console_register_storage(config, storage); diag_console_register_soil(soil_sensor, modbus_client); diag_console_register_env(env_sensor); + diag_console_register_level(level_low, level_high); esp_err_t err = diag_console_start(); if (err != ESP_OK) { // Console is a diagnostic aid, not a safety function: log and keep @@ -238,11 +275,15 @@ extern "C" void app_main(void) // sensor failed init above — lazy re-init recovers later (parity). sensor_task_start(env_sensor); - // Main loop: poll pump enforcement at 10 Hz. update() applies the timed - // self-stop and the hard 300 s max-runtime cap. + // Main loop: poll pump enforcement and the level sensors at 10 Hz. + // Pump update() applies the timed self-stop and the hard 300 s + // max-runtime cap; level update() samples the raw pins and advances + // the settle/debounce state machines (~3 samples per 300 ms window). while (true) { plant.update(); reservoir.update(); + level_low.update(); + level_high.update(); vTaskDelay(pdMS_TO_TICKS(100)); } } diff --git a/firmware/main/diag_console.cpp b/firmware/main/diag_console.cpp index 24f566d..b815d68 100644 --- a/firmware/main/diag_console.cpp +++ b/firmware/main/diag_console.cpp @@ -45,6 +45,13 @@ * * env # one read(); T/RH/P or ERROR * + * Level sensor command (HIL verification path for feature 006; console + * contract in specs/006-level-sensors-ina226/contracts/interfaces.md — the + * output distinguishes "not_yet_valid" from wet/dry, SC-005/FR-012, and + * shows the logical + raw state per sensor): + * + * level # both sensors: logical + raw + validity + * * Handler exit codes follow the esp_console convention: 0 on OK, 1 on ERR. * * State is plain pointers/PODs set from app_main — no non-trivial static @@ -67,6 +74,7 @@ #include "interfaces/IConfigStore.h" #include "interfaces/IDataStorage.h" #include "interfaces/IEnvironmentalSensor.h" +#include "interfaces/ILevelSensor.h" #include "interfaces/IModbusClient.h" #include "interfaces/ISoilSensor.h" #include "interfaces/IWaterPump.h" @@ -102,6 +110,12 @@ IModbusClient *s_modbus = nullptr; // LockedEnvironmentalSensor decorator). Same trivial-initialization rule. IEnvironmentalSensor *s_env = nullptr; +// Level sensors (set from app_main; expected to be the LockedLevelSensor +// decorators — the handler runs on the REPL task, concurrently with the +// main-loop update()). Same trivial-initialization rule. +ILevelSensor *s_level_low = nullptr; +ILevelSensor *s_level_high = nullptr; + const char *stop_reason_str(StopReason reason) { switch (reason) { @@ -647,6 +661,42 @@ int env_cmd(int argc, char **argv) return 0; } +// --- level command (feature 006 HIL verification path) ------------------- + +/// One sensor's console word: "not_yet_valid" is a DISTINCT state, never +/// conflated with wet/dry (SC-005/FR-012 — a settling or warming-up sensor +/// must not read as an empty or full reservoir). +const char *level_state_str(ILevelSensor &sensor) +{ + // isValid() and isWaterPresent() are separate locked calls; a main-loop + // update() interleaving between them can only make a just-valid reading + // report not_yet_valid or vice versa (benign cross-lock race, + // TODO(PR-11) snapshot helper in LockedLevelSensor.h). + if (!sensor.isValid()) { + return "not_yet_valid"; + } + return sensor.isWaterPresent() ? "water" : "dry"; +} + +/// `level`: both sensors' logical + raw + validity; thin wrapper, no logic. +/// The raw pin level is diagnostics (polarity NOT absorbed — see board.h). +int level_cmd(int argc, char **argv) +{ + (void)argv; + if (argc != 1) { + printf("ERR usage: level\n"); + return 1; + } + if (s_level_low == nullptr || s_level_high == nullptr) { + printf("ERR level sensors not available\n"); + return 1; + } + printf("OK low=%s (raw=%d) high=%s (raw=%d)\n", + level_state_str(*s_level_low), s_level_low->rawState() ? 1 : 0, + level_state_str(*s_level_high), s_level_high->rawState() ? 1 : 0); + return 0; +} + } // namespace void diag_console_register_pumps(IWaterPump& plant, IWaterPump& reservoir) @@ -672,6 +722,12 @@ void diag_console_register_env(IEnvironmentalSensor& sensor) s_env = &sensor; } +void diag_console_register_level(ILevelSensor& low, ILevelSensor& high) +{ + s_level_low = &low; + s_level_high = &high; +} + esp_err_t diag_console_start(void) { esp_console_repl_t *repl = nullptr; @@ -819,5 +875,20 @@ esp_err_t diag_console_start(void) return err; } + const esp_console_cmd_t cmd_level = { + .command = "level", + .help = "level — both level sensors: logical + raw state " + "(not_yet_valid distinct from water/dry)", + .hint = nullptr, + .func = &level_cmd, + .argtable = nullptr, + .func_w_context = nullptr, + .context = nullptr, + }; + err = esp_console_cmd_register(&cmd_level); + if (err != ESP_OK) { + return err; + } + return esp_console_start_repl(repl); } diff --git a/firmware/main/diag_console.h b/firmware/main/diag_console.h index 899be62..1f5e1cb 100644 --- a/firmware/main/diag_console.h +++ b/firmware/main/diag_console.h @@ -15,6 +15,7 @@ #include "interfaces/IConfigStore.h" #include "interfaces/IDataStorage.h" #include "interfaces/IEnvironmentalSensor.h" +#include "interfaces/ILevelSensor.h" #include "interfaces/IModbusClient.h" #include "interfaces/ISoilSensor.h" #include "interfaces/IWaterPump.h" @@ -61,6 +62,20 @@ void diag_console_register_soil(ISoilSensor& sensor, IModbusClient& client); */ void diag_console_register_env(IEnvironmentalSensor& sensor); +/** + * @brief Register the two level sensors the `level` command operates on + * (HIL verification path for feature 006). + * + * Pass the LockedLevelSensor decorators, never the raw sensors — the + * console handler runs on the REPL task, concurrently with the 10 Hz + * main-loop update(). Must be called before diag_console_start(); plain + * pointer registration. + * + * @param low Low-mark sensor (BOARD_PIN_LEVEL_LOW). + * @param high High-mark sensor (BOARD_PIN_LEVEL_HIGH). + */ +void diag_console_register_level(ILevelSensor& low, ILevelSensor& high); + /** * @brief Start the UART REPL (prompt "ws>") and register the commands. * diff --git a/firmware/test_apps/host/main/CMakeLists.txt b/firmware/test_apps/host/main/CMakeLists.txt index 533b8c8..48be977 100644 --- a/firmware/test_apps/host/main/CMakeLists.txt +++ b/firmware/test_apps/host/main/CMakeLists.txt @@ -5,5 +5,7 @@ idf_component_register( "test_data_storage.cpp" "test_soil_sensor.cpp" "test_bme280.cpp" + "test_level_sensor.cpp" + "test_ina226.cpp" REQUIRES unity actuators interfaces storage nvs_flash sensors ) diff --git a/firmware/test_apps/host/main/test_ina226.cpp b/firmware/test_apps/host/main/test_ina226.cpp new file mode 100644 index 0000000..9e16255 --- /dev/null +++ b/firmware/test_apps/host/main/test_ina226.cpp @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file test_ina226.cpp + * @brief Host tests for the INA226 path (linux target). + * + * Suite run_ina226_tests(), registered from the shared Unity runner + * (test_main.cpp); the process exit code equals the failure count and is + * the CI gate. + * + * This file currently covers tasks.md T006: the II2cBus 16-bit extension + * at the mock level — writeRegister16() recording big-endian into the + * per-address byte map + call log (byte-level assertions stay valid), the + * readRegister16() big-endian helper (non-virtual, over readRegisters()), + * outcome scripting shared across write widths, and the uint8_t register + * index wrap convention. The Ina226Sensor driver tests (T018: scaling + * vectors, identity, error paths) are added to this suite by US3. + */ + +#include + +#include "unity.h" + +#include "sensors/testing/MockI2cBus.h" + +namespace { + +constexpr uint8_t kAddr = 0x40; ///< rev2 pump INA226 (A0 = A1 = GND) +constexpr uint8_t kRegConfig = 0x00; +constexpr uint8_t kRegCalibration = 0x05; + +// --------------------------------------------------------------------------- +// writeRegister16: big-endian into the byte map + call log (T006) +// --------------------------------------------------------------------------- + +void test_write16_lands_big_endian_in_register_map(void) +{ + MockI2cBus bus; + bus.addDevice(kAddr); + + TEST_ASSERT_TRUE(bus.writeRegister16(kAddr, kRegConfig, 0x4127)); + + // Byte-level view (the assertion style the BME280 suite relies on — + // 16-bit writes must not bypass the shared byte map): MSB first. + uint8_t raw[2] = {0, 0}; + TEST_ASSERT_TRUE(bus.readRegisters(kAddr, kRegConfig, raw, 2)); + TEST_ASSERT_EQUAL_HEX8(0x41, raw[0]); + TEST_ASSERT_EQUAL_HEX8(0x27, raw[1]); +} + +void test_write16_read16_roundtrip(void) +{ + MockI2cBus bus; + bus.addDevice(kAddr); + + // CAL = 2048 (0x0800) — the 5 mΩ / 0.5 mA operating point. + TEST_ASSERT_TRUE(bus.writeRegister16(kAddr, kRegCalibration, 0x0800)); + + uint16_t value = 0; + TEST_ASSERT_TRUE(bus.readRegister16(kAddr, kRegCalibration, value)); + TEST_ASSERT_EQUAL_HEX16(0x0800, value); +} + +void test_write16_recorded_in_call_log(void) +{ + MockI2cBus bus; + bus.addDevice(kAddr); + + TEST_ASSERT_TRUE(bus.writeRegister16(kAddr, kRegConfig, 0x4127)); + + TEST_ASSERT_EQUAL(1, bus.calls.size()); + const MockI2cBus::Call& call = bus.calls[0]; + TEST_ASSERT_TRUE(call.type == MockI2cBus::Call::Type::Write16); + TEST_ASSERT_EQUAL_HEX8(kAddr, call.address); + TEST_ASSERT_EQUAL_HEX8(kRegConfig, call.reg); + TEST_ASSERT_EQUAL(2, call.len); + TEST_ASSERT_EQUAL_HEX16(0x4127, call.value16); + TEST_ASSERT_TRUE(call.succeeded); +} + +// --------------------------------------------------------------------------- +// readRegister16: big-endian decode of scripted bytes (T006) +// --------------------------------------------------------------------------- + +void test_read16_decodes_big_endian(void) +{ + MockI2cBus bus; + // Manufacturer ID register scripted byte-wise: 0x5449 ("TI") is + // {0x54, 0x49} MSB first on the wire. + bus.setRegisters(kAddr, 0xFE, {0x54, 0x49}); + + uint16_t value = 0; + TEST_ASSERT_TRUE(bus.readRegister16(kAddr, 0xFE, value)); + TEST_ASSERT_EQUAL_HEX16(0x5449, value); +} + +// --------------------------------------------------------------------------- +// Failure modes: absent device + scripted outcomes (T006) +// --------------------------------------------------------------------------- + +void test_write16_fails_on_absent_device(void) +{ + MockI2cBus bus; // nothing at kAddr — address NACK + + TEST_ASSERT_FALSE(bus.writeRegister16(kAddr, kRegConfig, 0x4127)); + TEST_ASSERT_EQUAL(1, bus.calls.size()); + TEST_ASSERT_FALSE(bus.calls[0].succeeded); + + uint16_t value = 0; + TEST_ASSERT_FALSE(bus.readRegister16(kAddr, kRegConfig, value)); +} + +void test_write16_queued_failure_leaves_map_untouched(void) +{ + MockI2cBus bus; + bus.addDevice(kAddr); + bus.setRegisters(kAddr, kRegCalibration, {0xAA, 0xBB}); // pre-existing + + bus.queueWriteOutcome(false); // mid-sequence bus error on a PRESENT device + TEST_ASSERT_FALSE(bus.writeRegister16(kAddr, kRegCalibration, 0x0800)); + + // A failed write never lands in the register map. + uint16_t value = 0; + TEST_ASSERT_TRUE(bus.readRegister16(kAddr, kRegCalibration, value)); + TEST_ASSERT_EQUAL_HEX16(0xAABB, value); +} + +void test_write_outcome_queue_shared_across_widths_in_order(void) +{ + MockI2cBus bus; + bus.addDevice(kAddr); + + // ONE FIFO covers 8- and 16-bit writes in call order (documented mock + // contract) — an INA226 config-then-calibration script mixes widths + // freely. + bus.queueWriteOutcome(true); + bus.queueWriteOutcome(false); + TEST_ASSERT_TRUE(bus.writeRegister(kAddr, 0x10, 0x01)); // consumes #1 + TEST_ASSERT_FALSE(bus.writeRegister16(kAddr, 0x11, 0x0203)); // consumes #2 + TEST_ASSERT_TRUE(bus.writeRegister16(kAddr, 0x12, 0x0405)); // queue empty +} + +// --------------------------------------------------------------------------- +// Register index wrap at 0xFF (mock convention, matches setRegisters()) +// --------------------------------------------------------------------------- + +void test_write16_wraps_register_index(void) +{ + MockI2cBus bus; + bus.addDevice(kAddr); + + // MSB at 0xFF, LSB wraps to 0x00 (uint8_t index arithmetic — the same + // convention readRegisters()/setRegisters() use). + TEST_ASSERT_TRUE(bus.writeRegister16(kAddr, 0xFF, 0x1234)); + + uint8_t msb = 0; + uint8_t lsb = 0; + TEST_ASSERT_TRUE(bus.readRegisters(kAddr, 0xFF, &msb, 1)); + TEST_ASSERT_TRUE(bus.readRegisters(kAddr, 0x00, &lsb, 1)); + TEST_ASSERT_EQUAL_HEX8(0x12, msb); + TEST_ASSERT_EQUAL_HEX8(0x34, lsb); +} + +} // namespace + +void run_ina226_tests(void) +{ + RUN_TEST(test_write16_lands_big_endian_in_register_map); + RUN_TEST(test_write16_read16_roundtrip); + RUN_TEST(test_write16_recorded_in_call_log); + RUN_TEST(test_read16_decodes_big_endian); + RUN_TEST(test_write16_fails_on_absent_device); + RUN_TEST(test_write16_queued_failure_leaves_map_untouched); + RUN_TEST(test_write_outcome_queue_shared_across_widths_in_order); + RUN_TEST(test_write16_wraps_register_index); +} diff --git a/firmware/test_apps/host/main/test_level_sensor.cpp b/firmware/test_apps/host/main/test_level_sensor.cpp new file mode 100644 index 0000000..0354485 --- /dev/null +++ b/firmware/test_apps/host/main/test_level_sensor.cpp @@ -0,0 +1,424 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file test_level_sensor.cpp + * @brief Host tests for the DebouncedLevelSensor logic (linux target). + * + * Tests the REAL settle/debounce/polarity state machine + * (DebouncedLevelSensor) via a scripted IDigitalInput + FakeTimeProvider. + * Registered via run_level_sensor_tests() from the shared Unity runner + * (test_main.cpp); the process exit code equals the failure count and is + * the CI gate. + * + * Coverage maps to tasks.md T009 (US1): debounce boundary (change only + * after a full window; every flip restarts it), warm-up not-yet-valid, + * settle gating (the 500 ms rev2 case incl. notifyPowerOn() re-arm), + * polarity equivalence (both board polarities produce identical logical + * results for the same physical scenario), chatter collapsing to a single + * transition, update-stream purity (time without update() changes + * nothing) and the LockedLevelSensor delegation check. Fail-direction + * truth tests (checklist line 97) arrive with T022. + * + * Timing convention (DebouncedLevelSensor contract): a window of N ms is + * complete on the first update() where at least N ms have elapsed since + * it opened — N-1 ms is inside the window, N ms is past it. + */ + +#include + +#include "unity.h" + +#include "actuators/testing/FakeTimeProvider.h" +#include "interfaces/IDigitalInput.h" +#include "sensors/DebouncedLevelSensor.h" +#include "sensors/LockedLevelSensor.h" + +namespace { + +// Board-table values (data-model.md); the tests pin the policy against +// these, independent of which board the suite notionally runs on. +constexpr int64_t kDebounceMs = 300; +constexpr int64_t kSettleMs = 500; ///< rev2 (FW-3); rev1 uses 0 + +/// Scripted raw input: the test sets `level`, the sensor samples it. +struct ScriptedInput : IDigitalInput { + bool level = false; + bool read() override { return level; } +}; + +/// Advance the fake clock by @p ms, then poll once (the owner's cadence). +void step(DebouncedLevelSensor& sensor, FakeTimeProvider& time, int64_t ms) +{ + time.advance(ms); + sensor.update(); +} + +// --------------------------------------------------------------------------- +// Warm-up: not-yet-valid before the first stable window +// --------------------------------------------------------------------------- + +void test_warmup_invalid_until_first_stable_window(void) +{ + ScriptedInput input; + FakeTimeProvider time; + DebouncedLevelSensor sensor(input, time, /*activeLow=*/false, + kDebounceMs, /*settleMs=*/0); + + // Before any update: nothing is known. + TEST_ASSERT_FALSE(sensor.isValid()); + + // First update (settle 0 → warm-up opens here on the current raw). + input.level = true; + sensor.update(); + TEST_ASSERT_FALSE(sensor.isValid()); + + // One millisecond short of the window: still warming up. + step(sensor, time, kDebounceMs - 1); + TEST_ASSERT_FALSE(sensor.isValid()); + + // Window complete: valid, and the logical state is the stable raw + // (active HIGH: raw HIGH = water present). + step(sensor, time, 1); + TEST_ASSERT_TRUE(sensor.isValid()); + TEST_ASSERT_TRUE(sensor.isWaterPresent()); +} + +void test_warmup_flip_restarts_window(void) +{ + ScriptedInput input; + FakeTimeProvider time; + DebouncedLevelSensor sensor(input, time, /*activeLow=*/false, + kDebounceMs, /*settleMs=*/0); + + input.level = false; + sensor.update(); // warm-up opens on raw=0 + step(sensor, time, 200); + TEST_ASSERT_FALSE(sensor.isValid()); + + // Flip 200 ms in: the warm-up window restarts on the new value. + input.level = true; + step(sensor, time, 0); + step(sensor, time, kDebounceMs - 1); + TEST_ASSERT_FALSE(sensor.isValid()); + step(sensor, time, 1); + TEST_ASSERT_TRUE(sensor.isValid()); + TEST_ASSERT_TRUE(sensor.isWaterPresent()); +} + +// --------------------------------------------------------------------------- +// Update-stream purity: no update ⇒ no state change +// --------------------------------------------------------------------------- + +void test_time_without_update_changes_nothing(void) +{ + ScriptedInput input; + FakeTimeProvider time; + DebouncedLevelSensor sensor(input, time, /*activeLow=*/false, + kDebounceMs, /*settleMs=*/0); + + input.level = true; + sensor.update(); // warm-up opens + + // Hours pass without a single update(): the getters alone must never + // advance the state machine (ILevelSensor contract). + time.advance(3'600'000); + TEST_ASSERT_FALSE(sensor.isValid()); + TEST_ASSERT_FALSE(sensor.isValid()); // repeated reads: still nothing + + // The next update() sees a raw value that held across the gap — the + // window (opened at the first update) is long complete. + sensor.update(); + TEST_ASSERT_TRUE(sensor.isValid()); +} + +// --------------------------------------------------------------------------- +// Tracking: debounce boundary + flip semantics (US1 core) +// --------------------------------------------------------------------------- + +/// Drive a fresh active-HIGH sensor (settle 0) to TRACKING on @p raw. +void reach_tracking(DebouncedLevelSensor& sensor, ScriptedInput& input, + FakeTimeProvider& time, bool raw) +{ + input.level = raw; + sensor.update(); + step(sensor, time, kDebounceMs); + TEST_ASSERT_TRUE(sensor.isValid()); +} + +void test_tracking_change_only_after_full_window(void) +{ + ScriptedInput input; + FakeTimeProvider time; + DebouncedLevelSensor sensor(input, time, /*activeLow=*/false, + kDebounceMs, /*settleMs=*/0); + reach_tracking(sensor, input, time, /*raw=*/false); + TEST_ASSERT_FALSE(sensor.isWaterPresent()); + + // Raw flips to water; poll at the 10 Hz owner cadence: the reported + // state must hold until a full 300 ms window has passed (~3 samples). + input.level = true; + step(sensor, time, 0); // flip observed, window opens + for (int i = 0; i < 2; ++i) { + step(sensor, time, 100); // 100, 200 ms into the window + TEST_ASSERT_TRUE(sensor.isValid()); + TEST_ASSERT_FALSE(sensor.isWaterPresent()); + } + step(sensor, time, 99); // 299 ms: one short + TEST_ASSERT_FALSE(sensor.isWaterPresent()); + step(sensor, time, 1); // 300 ms: transition fires + TEST_ASSERT_TRUE(sensor.isValid()); + TEST_ASSERT_TRUE(sensor.isWaterPresent()); +} + +void test_tracking_flip_restarts_window(void) +{ + ScriptedInput input; + FakeTimeProvider time; + DebouncedLevelSensor sensor(input, time, /*activeLow=*/false, + kDebounceMs, /*settleMs=*/0); + reach_tracking(sensor, input, time, /*raw=*/true); + TEST_ASSERT_TRUE(sensor.isWaterPresent()); + + // Water starts draining: raw drops, but bounces once 150 ms in and + // drops again 50 ms later — EVERY flip restarts the window, so the + // change fires a full 300 ms after the LAST flip only. + input.level = false; + step(sensor, time, 0); // drop observed + step(sensor, time, 150); + input.level = true; // bounce back + step(sensor, time, 0); + step(sensor, time, 50); + input.level = false; // final drop; window restarts HERE + step(sensor, time, 0); + step(sensor, time, kDebounceMs - 1); + TEST_ASSERT_TRUE(sensor.isWaterPresent()); // still water: 299 ms + step(sensor, time, 1); + TEST_ASSERT_FALSE(sensor.isWaterPresent()); // dry after a full window + TEST_ASSERT_TRUE(sensor.isValid()); // tracking never loses validity +} + +void test_tracking_flip_back_cancels_pending_change(void) +{ + ScriptedInput input; + FakeTimeProvider time; + DebouncedLevelSensor sensor(input, time, /*activeLow=*/false, + kDebounceMs, /*settleMs=*/0); + reach_tracking(sensor, input, time, /*raw=*/true); + + // A 200 ms dip that returns to the stable value must never surface — + // no transition fires, and the returned-to state needs no new window. + input.level = false; + step(sensor, time, 0); + step(sensor, time, 200); + input.level = true; + step(sensor, time, 0); + for (int i = 0; i < 10; ++i) { + step(sensor, time, 100); + TEST_ASSERT_TRUE(sensor.isWaterPresent()); + } +} + +// --------------------------------------------------------------------------- +// Settle gating (FW-3, rev2 case) + notifyPowerOn() re-arm +// --------------------------------------------------------------------------- + +void test_settle_gating_500ms(void) +{ + ScriptedInput input; + FakeTimeProvider time; + // rev2 configuration: active LOW, 500 ms settle. Raw HIGH = dry. + DebouncedLevelSensor sensor(input, time, /*activeLow=*/true, + kDebounceMs, kSettleMs); + + input.level = true; + sensor.update(); // settle window opens at the FIRST update + TEST_ASSERT_FALSE(sensor.isValid()); + + // 499 ms: still settling — the raw value is not even being timed yet. + step(sensor, time, kSettleMs - 1); + TEST_ASSERT_FALSE(sensor.isValid()); + + // 500 ms: settle elapses, warm-up opens; validity needs a further + // full debounce window on top (settle + warm-up stack). + step(sensor, time, 1); + TEST_ASSERT_FALSE(sensor.isValid()); + step(sensor, time, kDebounceMs - 1); + TEST_ASSERT_FALSE(sensor.isValid()); + step(sensor, time, 1); + TEST_ASSERT_TRUE(sensor.isValid()); + // Active LOW: raw HIGH = water absent. + TEST_ASSERT_FALSE(sensor.isWaterPresent()); +} + +void test_notify_power_on_rearms_settle(void) +{ + ScriptedInput input; + FakeTimeProvider time; + DebouncedLevelSensor sensor(input, time, /*activeLow=*/true, + kDebounceMs, kSettleMs); + + // Reach TRACKING once (settle + warm-up). + input.level = false; // active LOW: water present + sensor.update(); + step(sensor, time, kSettleMs); + step(sensor, time, kDebounceMs); + TEST_ASSERT_TRUE(sensor.isValid()); + TEST_ASSERT_TRUE(sensor.isWaterPresent()); + + // Sensor rail power-cycles (PR-14 will do this for real): readings + // are invalid IMMEDIATELY, before any further update. + sensor.notifyPowerOn(); + TEST_ASSERT_FALSE(sensor.isValid()); + + // The full settle + warm-up sequence is required again, measured from + // the first update() after the power-on event. + time.advance(10'000); // dead time before the owner polls again + sensor.update(); // settle window opens HERE + step(sensor, time, kSettleMs - 1); + TEST_ASSERT_FALSE(sensor.isValid()); + step(sensor, time, 1); // settle done, warm-up opens + TEST_ASSERT_FALSE(sensor.isValid()); + step(sensor, time, kDebounceMs); + TEST_ASSERT_TRUE(sensor.isValid()); + TEST_ASSERT_TRUE(sensor.isWaterPresent()); +} + +// --------------------------------------------------------------------------- +// Polarity equivalence (FW-5): board polarity is fully absorbed +// --------------------------------------------------------------------------- + +void test_polarity_equivalence(void) +{ + // The same physical scenario on both boards: water arrives, chatters + // once, then stays. rev1 (active HIGH) sees raw = water; rev2 (active + // LOW) sees raw = !water. Logical outputs must be identical stepwise. + ScriptedInput inputHigh; + ScriptedInput inputLow; + FakeTimeProvider timeHigh; + FakeTimeProvider timeLow; + DebouncedLevelSensor rev1(inputHigh, timeHigh, /*activeLow=*/false, + kDebounceMs, /*settleMs=*/0); + DebouncedLevelSensor rev2(inputLow, timeLow, /*activeLow=*/true, + kDebounceMs, /*settleMs=*/0); + + const bool water[] = {false, false, false, false, true, false, + true, true, true, true, true, true, + true, true, true, true, true, true}; + for (bool present : water) { + inputHigh.level = present; // active HIGH: raw follows water + inputLow.level = !present; // active LOW: raw inverted (2N7002) + timeHigh.advance(100); + timeLow.advance(100); + rev1.update(); + rev2.update(); + TEST_ASSERT_EQUAL(rev1.isValid(), rev2.isValid()); + if (rev1.isValid()) { + TEST_ASSERT_EQUAL(rev1.isWaterPresent(), rev2.isWaterPresent()); + } + } + // Sanity: the scenario actually ended valid + water present. + TEST_ASSERT_TRUE(rev1.isValid()); + TEST_ASSERT_TRUE(rev1.isWaterPresent()); +} + +// --------------------------------------------------------------------------- +// Chatter collapses to a single transition (SC-005) +// --------------------------------------------------------------------------- + +void test_chatter_single_transition(void) +{ + ScriptedInput input; + FakeTimeProvider time; + DebouncedLevelSensor sensor(input, time, /*activeLow=*/false, + kDebounceMs, /*settleMs=*/0); + reach_tracking(sensor, input, time, /*raw=*/false); + + // Water sloshing at a mark: the raw input toggles every 50 ms for a + // second, then holds HIGH. Count reported transitions throughout. + int transitions = 0; + bool last = sensor.isWaterPresent(); + for (int i = 0; i < 20; ++i) { + input.level = (i % 2) != 0; + step(sensor, time, 50); + if (sensor.isWaterPresent() != last) { + ++transitions; + last = sensor.isWaterPresent(); + } + } + TEST_ASSERT_EQUAL_INT(0, transitions); // chatter never surfaces + TEST_ASSERT_TRUE(sensor.isValid()); // and validity never drops + + input.level = true; // slosh settles: water covers the mark + for (int i = 0; i < 6; ++i) { + step(sensor, time, 100); + if (sensor.isWaterPresent() != last) { + ++transitions; + last = sensor.isWaterPresent(); + } + } + TEST_ASSERT_EQUAL_INT(1, transitions); // exactly one clean transition + TEST_ASSERT_TRUE(sensor.isWaterPresent()); +} + +// --------------------------------------------------------------------------- +// rawState() diagnostics: undebounced, straight from the last sample +// --------------------------------------------------------------------------- + +void test_raw_state_is_undebounced(void) +{ + ScriptedInput input; + FakeTimeProvider time; + DebouncedLevelSensor sensor(input, time, /*activeLow=*/false, + kDebounceMs, /*settleMs=*/0); + reach_tracking(sensor, input, time, /*raw=*/false); + + // The raw view follows the pin immediately while the logical view + // still holds the debounced state. + input.level = true; + step(sensor, time, 100); + TEST_ASSERT_TRUE(sensor.rawState()); + TEST_ASSERT_FALSE(sensor.isWaterPresent()); +} + +// --------------------------------------------------------------------------- +// LockedLevelSensor: pure delegation (decorator adds a mutex, nothing else) +// --------------------------------------------------------------------------- + +void test_locked_level_sensor_delegates(void) +{ + ScriptedInput input; + FakeTimeProvider time; + DebouncedLevelSensor raw(input, time, /*activeLow=*/false, kDebounceMs, + /*settleMs=*/0); + LockedLevelSensor sensor(raw); + + input.level = true; + sensor.update(); + TEST_ASSERT_FALSE(sensor.isValid()); + time.advance(kDebounceMs); + sensor.update(); + TEST_ASSERT_TRUE(sensor.isValid()); + TEST_ASSERT_TRUE(sensor.isWaterPresent()); + TEST_ASSERT_TRUE(sensor.rawState()); + + sensor.notifyPowerOn(); + TEST_ASSERT_FALSE(sensor.isValid()); +} + +} // namespace + +void run_level_sensor_tests(void) +{ + RUN_TEST(test_warmup_invalid_until_first_stable_window); + RUN_TEST(test_warmup_flip_restarts_window); + RUN_TEST(test_time_without_update_changes_nothing); + RUN_TEST(test_tracking_change_only_after_full_window); + RUN_TEST(test_tracking_flip_restarts_window); + RUN_TEST(test_tracking_flip_back_cancels_pending_change); + RUN_TEST(test_settle_gating_500ms); + RUN_TEST(test_notify_power_on_rearms_settle); + RUN_TEST(test_polarity_equivalence); + RUN_TEST(test_chatter_single_transition); + RUN_TEST(test_raw_state_is_undebounced); + RUN_TEST(test_locked_level_sensor_delegates); +} diff --git a/firmware/test_apps/host/main/test_main.cpp b/firmware/test_apps/host/main/test_main.cpp index e076a04..6889b5f 100644 --- a/firmware/test_apps/host/main/test_main.cpp +++ b/firmware/test_apps/host/main/test_main.cpp @@ -18,6 +18,8 @@ void run_config_store_tests(void); void run_data_storage_tests(void); void run_soil_sensor_tests(void); void run_bme280_tests(void); +void run_level_sensor_tests(void); +void run_ina226_tests(void); // Unity requires setUp/tearDown definitions (shared by all suites). extern "C" void setUp(void) {} @@ -31,5 +33,7 @@ extern "C" void app_main(void) run_data_storage_tests(); run_soil_sensor_tests(); run_bme280_tests(); + run_level_sensor_tests(); + run_ina226_tests(); std::exit(UNITY_END()); } diff --git a/specs/006-level-sensors-ina226/tasks.md b/specs/006-level-sensors-ina226/tasks.md index 738ab7d..a11b64c 100644 --- a/specs/006-level-sensors-ina226/tasks.md +++ b/specs/006-level-sensors-ina226/tasks.md @@ -17,24 +17,28 @@ app_main/diag_console/test-harness files. ## Phase 2: Foundational -- [ ] T002 [P] Create `ILevelSensor` in `firmware/components/interfaces/include/interfaces/ILevelSensor.h` — update/isValid/isWaterPresent/rawState/notifyPowerOn, contract doc comments per contracts/interfaces.md (validity gating, polarity absorption, fail-direction notes) -- [ ] T003 [P] Create `IPowerSensor` in `firmware/components/interfaces/include/interfaces/IPowerSensor.h` — established sensor validity family (initialize/read/isAvailable/getLastError 0/1/2, NaN-before-first-success getters: busVoltage/current/power) -- [ ] T004 Extend `II2cBus` in `firmware/components/interfaces/include/interfaces/II2cBus.h`: new virtual `writeRegister16(addr7, reg, uint16_t)` (big-endian, ONE transaction, doc: required by INA226 config/cal writes) + non-virtual `readRegister16` helper over readRegisters; update the PR-05 delegation note to point at this resolution -- [ ] T005 Implement `writeRegister16` in `firmware/components/sensors/src/EspI2cBus.cpp` + `include/sensors/EspI2cBus.h` (3-byte i2c_master_transmit, same lock/log discipline as existing methods) -- [ ] T006 Extend `MockI2cBus` in `firmware/components/sensors/include/sensors/testing/MockI2cBus.h`: 16-bit writes recorded big-endian into the per-address byte map + call log; scripting outcomes for 16-bit writes; host test for the extension in `firmware/test_apps/host/main/test_ina226.cpp` (mock-level roundtrip) -- [ ] T007 [P] Board flags in `firmware/components/board/include/board/board.h` per data-model.md table: `BOARD_HAS_RESERVOIR_PUMP` (rev1=1; rev2=0 + `BOARD_PIN_RESERVOIR_PUMP` REMOVED), `BOARD_PIN_LEVEL_LOW/HIGH` (32/33 both), `BOARD_LEVEL_ACTIVE_LOW` (0/1), `BOARD_LEVEL_DEBOUNCE_MS` (300), `BOARD_LEVEL_SETTLE_MS` (0/500), `BOARD_INA226_ADDR` (0x40 rev2 + address-map comment); flag-guard existing reservoir-pin sanity checks, add flag⇒pin consistency assert + level-pin distinctness asserts +- [x] T002 [P] Create `ILevelSensor` in `firmware/components/interfaces/include/interfaces/ILevelSensor.h` — update/isValid/isWaterPresent/rawState/notifyPowerOn, contract doc comments per contracts/interfaces.md (validity gating, polarity absorption, fail-direction notes) +- [x] T003 [P] Create `IPowerSensor` in `firmware/components/interfaces/include/interfaces/IPowerSensor.h` — established sensor validity family (initialize/read/isAvailable/getLastError 0/1/2, NaN-before-first-success getters: busVoltage/current/power) +- [x] T004 Extend `II2cBus` in `firmware/components/interfaces/include/interfaces/II2cBus.h`: new virtual `writeRegister16(addr7, reg, uint16_t)` (big-endian, ONE transaction, doc: required by INA226 config/cal writes) + non-virtual `readRegister16` helper over readRegisters; update the PR-05 delegation note to point at this resolution +- [x] T005 Implement `writeRegister16` in `firmware/components/sensors/src/EspI2cBus.cpp` + `include/sensors/EspI2cBus.h` (3-byte i2c_master_transmit, same lock/log discipline as existing methods) +- [x] T006 Extend `MockI2cBus` in `firmware/components/sensors/include/sensors/testing/MockI2cBus.h`: 16-bit writes recorded big-endian into the per-address byte map + call log; scripting outcomes for 16-bit writes; host test for the extension in `firmware/test_apps/host/main/test_ina226.cpp` (mock-level roundtrip) +- [x] T007 [P] Board flags in `firmware/components/board/include/board/board.h` per data-model.md table: `BOARD_HAS_RESERVOIR_PUMP` (rev1=1; rev2=0 + `BOARD_PIN_RESERVOIR_PUMP` REMOVED), `BOARD_PIN_LEVEL_LOW/HIGH` (32/33 both), `BOARD_LEVEL_ACTIVE_LOW` (0/1), `BOARD_LEVEL_DEBOUNCE_MS` (300), `BOARD_LEVEL_SETTLE_MS` (0/500), `BOARD_INA226_ADDR` (0x40 rev2 + address-map comment); flag-guard existing reservoir-pin sanity checks, add flag⇒pin consistency assert + level-pin distinctness asserts ## Phase 3: User Story 1 — Trustworthy water-level status (P1) 🎯 MVP -- [ ] T008 [US1] Implement `DebouncedLevelSensor` (pure) in `firmware/components/sensors/include/sensors/DebouncedLevelSensor.h` + `src/DebouncedLevelSensor.cpp`: injected raw-input source + ITimeProvider; settle gating (notifyPowerOn per FW-3, CP1 decision A) → warm-up → tracking with stability-window debounce (flip restarts window); polarity from constructor parameter (board macro at wiring site); state machine per data-model.md -- [ ] T009 [P] [US1] Host tests in `firmware/test_apps/host/main/test_level_sensor.cpp` (new suite `run_level_sensor_tests()`): debounce boundary (change only after window; flip restarts), warm-up not-yet-valid, settle gating (500 ms rev2 case incl. notifyPowerOn re-arm), polarity equivalence (same scenario both polarities → identical logical result), chatter → single transition -- [ ] T010 [P] [US1] Implement `GpioLevelSensor` raw-input provider (target-only) in `firmware/components/sensors/include/sensors/GpioLevelSensor.h` + `src/GpioLevelSensor.cpp`: input + internal pull-up (both boards, R4), no logic; CMakeLists linux-exclusion -- [ ] T011 [P] [US1] Implement `LockedLevelSensor` in `firmware/components/sensors/include/sensors/LockedLevelSensor.h` (per-call mutex decorator, established pattern + cross-call limitation note) -- [ ] T012 [US1] `level` console command: `diag_console_register_level(ILevelSensor &low, ILevelSensor &high)` in `firmware/main/diag_console.h/.cpp` — thin wrapper; output distinguishes not-yet-valid from wet/dry, shows logical + raw per sensor -- [ ] T013 [US1] Wire level sensors in `firmware/main/app_main.cpp`: two GpioLevelSensor + DebouncedLevelSensor (+Locked wrappers), polarity/settle from board macros, `notifyPowerOn()` at boot, `update()` calls in the 10 Hz main loop, console registration before start +- [x] T008 [US1] Implement `DebouncedLevelSensor` (pure) in `firmware/components/sensors/include/sensors/DebouncedLevelSensor.h` + `src/DebouncedLevelSensor.cpp`: injected raw-input source + ITimeProvider; settle gating (notifyPowerOn per FW-3, CP1 decision A) → warm-up → tracking with stability-window debounce (flip restarts window); polarity from constructor parameter (board macro at wiring site); state machine per data-model.md +- [x] T009 [P] [US1] Host tests in `firmware/test_apps/host/main/test_level_sensor.cpp` (new suite `run_level_sensor_tests()`): debounce boundary (change only after window; flip restarts), warm-up not-yet-valid, settle gating (500 ms rev2 case incl. notifyPowerOn re-arm), polarity equivalence (same scenario both polarities → identical logical result), chatter → single transition +- [x] T010 [P] [US1] Implement `GpioLevelSensor` raw-input provider (target-only) in `firmware/components/sensors/include/sensors/GpioLevelSensor.h` + `src/GpioLevelSensor.cpp`: input + internal pull-up (both boards, R4), no logic; CMakeLists linux-exclusion +- [x] T011 [P] [US1] Implement `LockedLevelSensor` in `firmware/components/sensors/include/sensors/LockedLevelSensor.h` (per-call mutex decorator, established pattern + cross-call limitation note) +- [x] T012 [US1] `level` console command: `diag_console_register_level(ILevelSensor &low, ILevelSensor &high)` in `firmware/main/diag_console.h/.cpp` — thin wrapper; output distinguishes not-yet-valid from wet/dry, shows logical + raw per sensor +- [x] T013 [US1] Wire level sensors in `firmware/main/app_main.cpp`: two GpioLevelSensor + DebouncedLevelSensor (+Locked wrappers), polarity/settle from board macros, `notifyPowerOn()` at boot, `update()` calls in the 10 Hz main loop, console registration before start ## Phase 4: User Story 2 — One firmware, one-pump and two-pump boards (P2) + > NOTE (implementer, Mission 1): T007 removed `BOARD_PIN_RESERVOIR_PUMP` from + > the rev2 board profile, so the REV2 TARGET BUILD IS EXPECTED RED until T014 + > guards app_main's unconditional references (task-order artifact of the + > Mission 1/2 split). Host suite and rev1 target are unaffected. - [ ] T014 [US2] Capability-gate reservoir pump wiring in `firmware/main/app_main.cpp`: instance creation, boot force-OFF and any references under `#if BOARD_HAS_RESERVOIR_PUMP`; verify the force-off-first invariant reads correctly on both boards (rev2 forces off exactly the plant pump) - [ ] T015 [US2] Gate `pump reservoir` console registration in `firmware/main/diag_console.cpp` (compile-time absence on flag=0; `pump status` reports exactly the existing pumps) - [ ] T016 [P] [US2] Host tests for capability gating in `firmware/test_apps/host/main/test_level_sensor.cpp` or existing pump suite: whatever pump-wiring logic is host-reachable stays green on both configs; at minimum assert the board-header contract compiles both ways (a compile-time test: rev1 path references the pin, shared code does not) — plus regression: full existing pump suite untouched (SC-004) @@ -50,7 +54,12 @@ app_main/diag_console/test-harness files. - [ ] T021 [P] [US4] Create `MockLevelSensor` in `firmware/components/sensors/include/sensors/testing/MockLevelSensor.h` with consistency helpers (`scriptValidState(present)`, `scriptInvalid()`); host tests proving all four PR-11 truth-table combinations expressible across two instances (incl. low-dry+high-wet) with coherent validity — the SC-006 consumer-style test - [ ] T022 [P] [US4] Fail-direction truth tests in `firmware/test_apps/host/main/test_level_sensor.cpp`: disconnected-input simulation (raw pulled HIGH) → rev1 config reads "water present", rev2 config reads "water absent" — checklist line 97 pinned per board with a comment citing the pump-topology safety rationale -- [ ] T023 [US4] Register both new suites in `firmware/test_apps/host/main/CMakeLists.txt` + `test_main.cpp` (`run_level_sensor_tests`, `run_ina226_tests`) +- [x] T023 [US4] Register both new suites in `firmware/test_apps/host/main/CMakeLists.txt` + `test_main.cpp` (`run_level_sensor_tests`, `run_ina226_tests`) + > NOTE (implementer, Mission 1): pulled forward and completed together with + > T006/T009 — both suite files must exist and be registered for the host app + > to build, so `run_level_sensor_tests` and `run_ina226_tests` were wired into + > `test_main.cpp`/`CMakeLists.txt` in Phase 2–3. T021/T022 later APPEND tests + > to these suites; no further registration work remains. ## Phase 7: Polish & Cross-Cutting From 2444989cbf603f1201c1b11c2a4708bed9496e07 Mon Sep 17 00:00:00 2001 From: Cryptotomte Date: Fri, 3 Jul 2026 07:02:29 +0200 Subject: [PATCH 3/4] feat(sensors): reservoir-pump capability gating and INA226 power telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit US2-US4 + polish: app_main/diag_console fully capability-gated (BOARD_HAS_RESERVOIR_PUMP - rev2 binary contains exactly one pump, compile-time enforced; force-off-first invariant capability-aware); Ina226Sensor pure driver (TI identity check, config 0x4527 derived from the datasheet field layout with TODO(PR-14) hardware confirmation, CAL=2048 @ 0.5 mA/5 mOhm from Kconfig WS_INA226_SHUNT_MILLIOHM, datasheet scaling incl. signed current) on the shared I2C bus with a 16-bit word-register overlay in MockI2cBus; power/level console commands; MockLevelSensor with PR-11 truth-table consumer test; fail-direction truths pinned per board; board-contract compile-time TUs; firmware/CLAUDE.md + parity-checklist §6 divergences + line-96 measurement slot + HIL checklist. Full verification: host 156/0, rev1+rev2 green from clean, dependencies.lock unchanged (T014-T027). Co-Authored-By: Claude Fable 5 --- docs/parity-checklist.md | 41 ++ firmware/CLAUDE.md | 134 +++++- firmware/components/sensors/CMakeLists.txt | 28 +- .../sensors/include/sensors/Ina226Sensor.h | 158 +++++++ .../include/sensors/LockedPowerSensor.h | 111 +++++ .../include/sensors/testing/MockI2cBus.h | 58 ++- .../include/sensors/testing/MockLevelSensor.h | 94 +++++ .../components/sensors/src/Ina226Sensor.cpp | 232 +++++++++++ firmware/main/Kconfig.projbuild | 13 + firmware/main/app_main.cpp | 109 ++++- firmware/main/diag_console.cpp | 122 +++++- firmware/main/diag_console.h | 26 ++ firmware/test_apps/host/main/CMakeLists.txt | 7 +- .../host/main/test_board_contract_rev1.cpp | 55 +++ .../host/main/test_board_contract_rev2.cpp | 54 +++ firmware/test_apps/host/main/test_ina226.cpp | 390 +++++++++++++++++- .../test_apps/host/main/test_level_sensor.cpp | 125 +++++- .../checklists/hil.md | 74 ++++ specs/006-level-sensors-ina226/tasks.md | 28 +- 19 files changed, 1775 insertions(+), 84 deletions(-) create mode 100644 firmware/components/sensors/include/sensors/Ina226Sensor.h create mode 100644 firmware/components/sensors/include/sensors/LockedPowerSensor.h create mode 100644 firmware/components/sensors/include/sensors/testing/MockLevelSensor.h create mode 100644 firmware/components/sensors/src/Ina226Sensor.cpp create mode 100644 firmware/test_apps/host/main/test_board_contract_rev1.cpp create mode 100644 firmware/test_apps/host/main/test_board_contract_rev2.cpp create mode 100644 specs/006-level-sensors-ina226/checklists/hil.md diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index b40f8cd..635f007 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -94,6 +94,12 @@ State machine (`src/main.cpp:492-552`, called every main-loop pass): - [ ] `[HOST]` Status query reads the level pins live and reports low/high/running (`src/main.cpp:649-665`) - [ ] `[HIL]` Level sensor inputs: low = GPIO 32, high = GPIO 33, configured with internal pull-ups (`src/main.cpp:37-38`, `231-233`) - [ ] `[HIL]` **Level sensor polarity — verify by measurement, not by parity.** The Arduino code *as it stands today* reads the sensors as active HIGH (water = HIGH: `src/main.cpp:504-506`, `567`, `651-656`). Note: PRD FR5 states the Arduino code reads active LOW — that described an earlier revision; the fix from the 2026-04-12 fix-branch is already merged on this branch. Polarity facts per FR5: XKC-Y26 OUT is active HIGH; rev1 (direct via TXS0108E, non-inverting) ⇒ GPIO active HIGH; rev2 (via 2N7002 inverter) ⇒ GPIO active LOW. **The checklist target is CORRECT behavior driven by board configuration (`BOARD_REV1_DEVKIT`/`BOARD_REV2` Kconfig), not bug-for-bug parity. Final polarity must be verified by measurement on the bench rig in Phase 1 and the result recorded here.** + > **REV1 BENCH MEASUREMENT RECORD (feature 006 HIL, checklist item A — + > DO NOT fill in from code or docs; measured values only):** + > - Water present at sensor → GPIO level measured: `____` (expected HIGH) + > - No water at sensor → GPIO level measured: `____` (expected LOW) + > - `level` console output matched the physical state: `____` (yes/no) + > - Measured by / date: `____` - [ ] `[HIL]` Pull-up + active-HIGH consequence on rev1: a disconnected/floating sensor reads HIGH = "water present", which keeps the fill pump off (fails toward "do not pump"). Confirm equivalent fail-direction on rev2 (inverted polarity ⇒ pull state must be re-chosen per board config). (`src/main.cpp:231-233`) ## 4. Web API @@ -241,6 +247,41 @@ parity targets; each is the new contract behavior, host-tested in web server); the port serializes every interface call through the mutex decorator — same pattern as the other Locked* wrappers (spec 005 FR-010). +### Deliberate divergences in the ESP-IDF port (feature 006, PR-05) + +Intentional behavior changes from the Arduino level-sensor handling +(section 3) plus the new single-pump capability and INA226 surface; each is +the new contract behavior, host-tested in +`firmware/test_apps/host/main/test_level_sensor.cpp` / +`test_ina226.cpp` (contracts: +`specs/006-level-sensors-ina226/contracts/interfaces.md`). + +- [ ] `[HOST]` **Stability-window debounce on the level inputs**: legacy + reads the bare pins every main-loop pass (`src/main.cpp:504-506`); the + port changes the reported logical state only after the raw input has held + a new value for `BOARD_LEVEL_DEBOUNCE_MS` (300 ms) — any flip restarts + the window, so chatter at the water line collapses to a single transition + (spec 006 FR-003/SC-005). +- [ ] `[HOST]` **Explicit not-yet-valid state**: legacy has no validity + concept — a just-booted or just-powered sensor is read as-is; the port + gates readings behind settle time (FW-3: rev2 500 ms after power-on, + rev1 0) plus a debounce warm-up, and reports a DISTINCT not-yet-valid + state that consumers must never conflate with wet or dry (spec 006 + FR-001/FR-004/SC-005; PR-11 treats invalid as "do not act"). +- [ ] `[HOST]` **INA226 identity check**: new capability, no legacy INA226 + at all (section 9) — the driver verifies manufacturer ID (0xFE == + 0x5449) and die ID (0xFF == 0x2260) at initialization and rejects + foreign devices with a distinct error, mirroring the BME280 chip-ID + divergence above (spec 006 FR-009). +- [ ] `[HOST]` **Reservoir pump capability flag**: legacy always compiles + both pumps; the port gates ALL reservoir-pump wiring (instance, boot + force-OFF, console registration) behind `BOARD_HAS_RESERVOIR_PUMP` + (rev1 = 1, rev2 = 0 — single-pump decision, master PRD FR4, final + 2026-06-10). On rev2 the pump pin macro is REMOVED so unguarded + references fail the build; the boot invariant becomes "every pump that + EXISTS is forced OFF first" (spec 006 FR-006/FR-007; the reservoir + parity items in sections 2–3 remain rev1-only per section 9). + ## 7. WiFi / network / time - [ ] `[HIL]` STA connect at boot using saved credentials: STA mode, auto-reconnect off (handled manually), WiFi modem sleep disabled, clean disconnect first, **60 s** connect timeout with LED toggling every 500 ms (`src/main.cpp:46`, `168-212`) diff --git a/firmware/CLAUDE.md b/firmware/CLAUDE.md index ae6270f..28a4fbb 100644 --- a/firmware/CLAUDE.md +++ b/firmware/CLAUDE.md @@ -30,11 +30,19 @@ must stay green. Logic is unit-tested natively, no ESP32 needed: pump enforcement, config store, data storage, the soil sensor decode/validation/calibration -(`test_soil_sensor.cpp`, real `ModbusSoilSensor` over `MockModbusClient`) -and the BME280 probe/calibration/compensation logic (`test_bme280.cpp`, +(`test_soil_sensor.cpp`, real `ModbusSoilSensor` over `MockModbusClient`), +the BME280 probe/calibration/compensation logic (`test_bme280.cpp`, real `Bme280Sensor` over `MockI2cBus` — Bosch reference vectors, error paths, address variants, sensor-task log policy, `MockEnvironmentalSensor` -consistency). The test executable's exit code equals the Unity failure +consistency), the level-sensor state machine (`test_level_sensor.cpp`, +real `DebouncedLevelSensor` over a scripted input + `FakeTimeProvider` — +debounce/settle/polarity, per-board fail-direction truths, +`MockLevelSensor` coherence) and the INA226 driver (`test_ina226.cpp`, +real `Ina226Sensor` over `MockI2cBus` — datasheet scaling vectors, +identity/absent/recovery paths, 16-bit bus extension). Two +`test_board_contract_rev*.cpp` TUs compile the REAL `board.h` under each +board selector and static_assert the capability contract (passing = +compiling). The test executable's exit code equals the Unity failure count (CI gate, job `host-test`): ```bash @@ -58,7 +66,7 @@ firmware/ │ ├── app_main.cpp # Entry point — pumps forced OFF first, always │ ├── diag_console.cpp/.h # esp_console UART REPL (prompt "ws>") │ ├── sensor_task.cpp/.h # 5 s environmental poll task (feature 005) -│ ├── Kconfig.projbuild # Board revision choice +│ ├── Kconfig.projbuild # Board revision choice + WS_INA226_SHUNT_MILLIOHM │ └── idf_component.yml # Pinned managed deps (esp-modbus, littlefs) ├── components/ │ ├── board/ # Board abstraction (header-only) @@ -67,23 +75,30 @@ firmware/ │ │ └── include/interfaces/ # IActuator, IWaterPump, ITimeProvider, │ │ # IConfigStore, IDataStorage, │ │ # IModbusClient, ISoilSensor, -│ │ # IEnvironmentalSensor, II2cBus +│ │ # IEnvironmentalSensor, II2cBus, +│ │ # IDigitalInput, ILevelSensor, IPowerSensor │ ├── actuators/ # Pump drivers │ │ ├── include/actuators/ # WaterPump (pure C++ logic), GpioWaterPump, │ │ │ # EspTimeProvider (esp32-only header), │ │ │ # testing/ (MockWaterPump, FakeTimeProvider) │ │ └── src/ # GpioWaterPump.cpp excluded on linux target -│ ├── sensors/ # Soil sensor (feature 004) + BME280 (005) -│ │ ├── include/sensors/ # ModbusSoilSensor, Bme280Sensor (pure C++ -│ │ │ # logic), EspModbusClient, EspI2cBus, -│ │ │ # LockedSoilSensor, +│ ├── sensors/ # Soil sensor (004) + BME280 (005) + +│ │ │ # level sensors + INA226 (006) +│ │ ├── include/sensors/ # ModbusSoilSensor, Bme280Sensor, +│ │ │ # DebouncedLevelSensor, Ina226Sensor (pure +│ │ │ # C++ logic), EspModbusClient, EspI2cBus, +│ │ │ # GpioLevelSensor, LockedSoilSensor, │ │ │ # LockedEnvironmentalSensor, +│ │ │ # LockedLevelSensor, LockedPowerSensor, │ │ │ # SensorTaskLogPolicy, testing/ │ │ │ # (MockModbusClient, MockSoilSensor, -│ │ │ # MockI2cBus, MockEnvironmentalSensor) -│ │ └── src/ # EspModbusClient.cpp + esp-modbus dep and -│ │ # EspI2cBus.cpp + esp_driver_i2c dep -│ │ # excluded on linux target +│ │ │ # MockI2cBus, MockEnvironmentalSensor, +│ │ │ # MockLevelSensor) +│ │ └── src/ # EspModbusClient.cpp + esp-modbus dep, +│ │ # EspI2cBus.cpp + esp_driver_i2c dep and +│ │ # GpioLevelSensor.cpp excluded on linux +│ │ # target; Ina226Sensor.cpp on linux always +│ │ # + on target only when CONFIG_BOARD_REV2 │ └── storage/ # Config + data persistence (feature 003) │ ├── include/storage/ # NvsConfigStore, LittleFsDataStorage (POSIX, │ │ # host-runnable), StorageMount (esp32-only), @@ -95,7 +110,10 @@ firmware/ └── host/ # Host test app (linux preview target, Unity): # pump + config store + data storage + # soil sensor (test_soil_sensor.cpp) + - # BME280 (test_bme280.cpp) suites + # BME280 (test_bme280.cpp) + + # level sensors (test_level_sensor.cpp) + + # INA226 (test_ina226.cpp) suites + + # board-contract TUs (compile-time) ``` Future components (drivers, controllers, web server) are added as siblings @@ -124,9 +142,14 @@ are normative in `specs/002-pump-gpio-board/contracts/serial-diagnostic.md`: pump start # timed run; 1..300 pump stop pump status -pump status # both pumps +pump status # every existing pump ``` +The pump set is capability-aware (feature 006): on +`BOARD_HAS_RESERVOIR_PUMP=0` boards (rev2, single-pump node) the +`reservoir` word is compiled out — `pump reservoir ...` is a usage error +and `pump status` reports exactly one pump (PR-14 contract). + Feature 003 adds `config` and `storage` subcommands (HIL verification path; the handlers are thin interface calls, no logic). Credential values are never echoed (FR-004): @@ -154,6 +177,17 @@ from error 2 "read failed" — SC-006): env # one read(); T/RH/P with units or ERROR () ``` +Feature 006 adds the level-status command (both boards; the output +distinguishes `not_yet_valid` from `water`/`dry` — a settling sensor never +reads as an empty or full reservoir) and the power-telemetry command +(`BOARD_HAS_INA226` builds only — compile-time absent on rev1; the PR-14 +bring-up path, same error-1-vs-2 hint convention as `env`): + +``` +level # both sensors: logical + raw pin state +power # one read(); bus V / current A / power W or ERROR () +``` + ## Storage (config + data persistence) Feature 003 (PR-06). Two redesigned, host-includable interfaces in @@ -208,9 +242,13 @@ Two board revisions exist, selected via Kconfig (`main/Kconfig.projbuild`): Rev2 pins are provisional until hardware sync 1 (`TODO(SYNC1)` markers). All pins and polarity/feature flags come from `board/board.h` -(`BOARD_PIN_*`, `BOARD_HAS_RS485_DE`, `BOARD_LEVEL_ACTIVE_LOW`, -`BOARD_HAS_INA226`, `BOARD_NAME`). Never hard-code GPIO numbers elsewhere. -Board-conditional code uses `#if CONFIG_BOARD_REV2` / `#if BOARD_HAS_INA226`. +(`BOARD_PIN_*`, `BOARD_HAS_RS485_DE`, `BOARD_HAS_RESERVOIR_PUMP`, +`BOARD_LEVEL_ACTIVE_LOW`, `BOARD_HAS_INA226`, `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. ## BME280 environmental sensor (I2C) @@ -249,6 +287,61 @@ from the legacy driver (address probing, last-good getters, live availability probe, locked access) are recorded in `docs/parity-checklist.md` §6. +## Reservoir level sensors (XKC-Y26) + +Feature 006 (PR-05). Two independent `ILevelSensor` instances (low mark +GPIO 32, high mark GPIO 33) — PR-11's controller composes its reservoir +truth table from them; this layer never aggregates. Split at the +`IDigitalInput` seam: `DebouncedLevelSensor` is pure C++ and holds ALL +policy — the SETTLING → WARMUP → TRACKING state machine, the +stability-window debounce (`BOARD_LEVEL_DEBOUNCE_MS`, 300 ms; any raw flip +restarts the window) and the polarity mapping — host-tested against a +scripted input + `FakeTimeProvider`; `GpioLevelSensor` is the only +hardware touchpoint (input + internal pull-up on BOTH boards, one +`gpio_get_level`, no logic) and is excluded from the linux build. + +**Polarity is board configuration (FW-5), never application `#ifdef`s:** +rev1 reads the XKC-Y26 directly (active HIGH), rev2 goes through a 2N7002 +inverter (active LOW) — `BOARD_LEVEL_ACTIVE_LOW` is passed to the +constructor at the wiring site. **Settle gating (FW-3):** readings report +not-yet-valid for `BOARD_LEVEL_SETTLE_MS` after a power-on event (rev1 0, +rev2 500 ms); `notifyPowerOn()` re-arms the gate — app_main calls it once +at boot, real rail control (`SENS_PWR_EN`) arrives with PR-14. Consumers +MUST gate on `isValid()`: not-yet-valid is a distinct state, never wet or +dry. Fail direction (pinned by host tests, parity checklist line 97): a +disconnected input reads pulled-HIGH ⇒ rev1 "water present" (fill pump +stays off), rev2 "water absent" (drawing node does not pump) — both fail +safe for their pump topology. `update()` is polled from the 10 Hz main +loop; cross-task access (console `level`) goes through +`LockedLevelSensor`. The capability flag `BOARD_HAS_RESERVOIR_PUMP` +(rev1 1, rev2 0 — single-pump decision, master PRD FR4) gates ALL +reservoir-pump wiring: instance, boot force-OFF and console registration +exist only where the pump does; on rev2 the pin macro is removed so +unguarded references fail the build. + +## INA226 pump power monitor (I2C, rev2 only) + +Feature 006 (PR-05). `Ina226Sensor` is pure C++ over `II2cBus` +(`IPowerSensor`: bus V / signed current A / power W) and clones the +BME280 architecture: identity check at init (manufacturer 0xFE == 0x5449, +die 0xFF == 0x2260 — foreign devices rejected with error 1), error codes +0/1/2, last-good getters with NaN placeholders, lazy re-init and +uninitialize-on-bus-error recovery. Config (AVG ×16, 1.1 ms conversions, +continuous shunt+bus — value derivation in `Ina226Sensor.cpp`) and +calibration (`CAL = 0.00512 / (Current_LSB × R_shunt)`, Current_LSB fixed +0.5 mA) are written at init; the shunt comes from Kconfig +(`CONFIG_WS_INA226_SHUNT_MILLIOHM`, default 5 mΩ ⇒ CAL 2048). + +**Shared-bus rule:** the driver receives app_main's ONE `EspI2cBus` +instance (the same the BME280 uses) — never a second bus on these pins. +Reads are on-demand (console `power` now, PR-09 API later; no periodic +task); cross-task access goes through `LockedPowerSensor`. Build gating +(FR-011): `Ina226Sensor.cpp` builds on linux always (host tests) and on +target only when `CONFIG_BOARD_REV2` — the rev1 binary contains no INA226 +code. **Hardware validation is deferred to PR-14** (no INA226 on the rev1 +rig): the driver is host-verified only, and the written config value +carries a `TODO(PR-14)` bench confirmation. + ## Partition layout (4MB flash) nvs (0x9000, 16K) | otadata (0xd000, 8K) | phy_init (0xf000, 4K) | @@ -261,8 +354,9 @@ must fit in 1.5MB per slot. - **C++ with IDF-native APIs only.** No Arduino compatibility layers. IDF v6 defaults to gnu++26; stick to ~C++23 features. - **`extern "C" void app_main(void)`** — app_main has C linkage. -- **Fail-safe first:** the first statements in `app_main` drive both pump - GPIOs to OFF. This invariant must survive every future change. +- **Fail-safe first:** the first statements in `app_main` drive every pump + GPIO that exists on the board to OFF (capability-aware since feature + 006). This invariant must survive every future change. - **No non-trivial static/global constructors.** They run before `app_main` and would execute ahead of (and thus bypass) the pump fail-safe. All initialization is explicit, inside or after `pumps_force_off()`. diff --git a/firmware/components/sensors/CMakeLists.txt b/firmware/components/sensors/CMakeLists.txt index 7dd336c..0e89e2c 100644 --- a/firmware/components/sensors/CMakeLists.txt +++ b/firmware/components/sensors/CMakeLists.txt @@ -1,20 +1,26 @@ # sensors — sensor driver layer (RS485 Modbus soil sensor + I2C BME280 + -# reservoir level sensors). +# reservoir level sensors + I2C INA226 power monitor). # -# ModbusSoilSensor.cpp, Bme280Sensor.cpp and DebouncedLevelSensor.cpp are -# pure C++ (decode/validation/calibration resp. probe/compensation resp. -# settle/debounce/polarity logic) and build on every target, including the -# linux preview target used by the host test suite. +# ModbusSoilSensor.cpp, Bme280Sensor.cpp, DebouncedLevelSensor.cpp and +# Ina226Sensor.cpp are pure C++ (decode/validation/calibration resp. +# probe/compensation resp. settle/debounce/polarity resp. identity/scaling +# logic) and build on the linux preview target used by the host test suite. # EspModbusClient.cpp (esp-modbus master + UART RS485 half-duplex + RX # pull-up), EspI2cBus.cpp (i2c_master bus owner) and GpioLevelSensor.cpp # (raw GPIO level input) are the only hardware touchpoints and are # excluded — together with their driver/esp-modbus dependencies — when # building for linux (research.md R7/005 R6/006 R1, same mechanism as # storage/actuators). +# +# Ina226Sensor.cpp is additionally board-gated on device targets (006 +# analyze finding I1, FR-011): only INA226-equipped boards +# (CONFIG_BOARD_REV2) compile it — the rev1 binary provably contains no +# INA226 code. The linux target keeps it unconditionally for the host +# tests (the test app defines no board choice at all). if(${IDF_TARGET} STREQUAL "linux") idf_component_register( SRCS "src/ModbusSoilSensor.cpp" "src/Bme280Sensor.cpp" - "src/DebouncedLevelSensor.cpp" + "src/DebouncedLevelSensor.cpp" "src/Ina226Sensor.cpp" INCLUDE_DIRS "include" REQUIRES interfaces board ) @@ -26,11 +32,15 @@ else() # driver and i2c_master headers appear only in src/EspModbusClient.cpp # / src/EspI2cBus.cpp / src/GpioLevelSensor.cpp / private code, never # in this component's public headers (same rule as storage's littlefs). - idf_component_register( - SRCS "src/ModbusSoilSensor.cpp" "src/Bme280Sensor.cpp" + set(srcs "src/ModbusSoilSensor.cpp" "src/Bme280Sensor.cpp" "src/DebouncedLevelSensor.cpp" "src/EspModbusClient.cpp" "src/EspI2cBus.cpp" - "src/GpioLevelSensor.cpp" + "src/GpioLevelSensor.cpp") + if(CONFIG_BOARD_REV2) + list(APPEND srcs "src/Ina226Sensor.cpp") + endif() + idf_component_register( + SRCS ${srcs} INCLUDE_DIRS "include" REQUIRES interfaces board PRIV_REQUIRES espressif__esp-modbus esp_driver_uart esp_driver_gpio diff --git a/firmware/components/sensors/include/sensors/Ina226Sensor.h b/firmware/components/sensors/include/sensors/Ina226Sensor.h new file mode 100644 index 0000000..5d462c0 --- /dev/null +++ b/firmware/components/sensors/include/sensors/Ina226Sensor.h @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file Ina226Sensor.h + * @brief Pure C++ INA226 driver logic over an injected II2cBus. + * + * New capability — no Arduino counterpart (docs/parity-checklist.md §9). + * Registers, scaling and error codes are normative in + * specs/006-level-sensors-ina226/data-model.md; field values are derived + * from the TI INA226 datasheet (SBOS547) — see the config-value breakdown + * in Ina226Sensor.cpp. + * + * This class holds ALL policy — identity verification (manufacturer/die + * ID, mirroring the BME280 chip-ID pattern), configuration + calibration + * writes, register scaling incl. the signed current, error codes, lazy + * re-initialization and uninitialize-on-bus-error recovery. It contains NO + * hardware access — every bus transaction goes through the injected + * II2cBus, so all of it is compiled and unit-tested on the IDF linux + * preview target against MockI2cBus (constitution II). On device targets + * the file is built only for INA226-equipped boards (CONFIG_BOARD_REV2, + * sensors/CMakeLists.txt) — the rev1 binary contains no INA226 code + * (FR-011). + * + * Concurrency: unsynchronized by design (host-testable); cross-task + * consumers (console REPL now; web PR-09, controller PR-11) wrap it in + * LockedPowerSensor and access it only through the wrapper. + */ + +#ifndef WATERINGSYSTEM_SENSORS_INA226SENSOR_H +#define WATERINGSYSTEM_SENSORS_INA226SENSOR_H + +#include +#include + +#include "interfaces/II2cBus.h" +#include "interfaces/IPowerSensor.h" + +/** + * @brief IPowerSensor over the INA226 register map. + * + * One read() = one snapshot: bus voltage, power and current registers are + * fetched together (the device converts continuously — MODE 0b111) and + * published atomically w.r.t. this object: on any failure the last-good + * getter values remain untouched and getLastError() carries the cause + * (0/1/2, data-model.md). + */ +class Ina226Sensor : public IPowerSensor { +public: + /// Identity registers (0xFE/0xFF) must read exactly these values — + /// a responding foreign device is rejected with error 1 (FR-009). + static constexpr uint16_t kManufacturerId = 0x5449; ///< "TI" in ASCII + static constexpr uint16_t kDieId = 0x2260; ///< INA226 die/rev + + /// Fixed current resolution (research.md R7/R9): 0.5 mA/LSB. Chosen + /// for the rev2 operating point — full scale ±0.5 mA × 2^15 ≈ ±16.4 A + /// on the 5 mΩ shunt, ample for a ~4 A pump. Compile-time constant; + /// promote to Kconfig only if a real need appears. + static constexpr float kCurrentLsbA = 0.0005f; + + /// Bus-voltage register resolution, fixed by the device: 1.25 mV/LSB + /// (SBOS547, Bus Voltage Register). + static constexpr float kBusVoltageLsbV = 0.00125f; + + /// Power register resolution, fixed by the device at 25 × Current_LSB + /// (SBOS547, Power Register) = 12.5 mW/LSB here. + static constexpr float kPowerLsbW = 25.0f * kCurrentLsbA; + + /** + * @brief Calibration register value for a given shunt (data-model.md). + * + * CAL = 0.00512 / (Current_LSB × R_shunt) + * = 0.00512 / (0.0005 A × R_mΩ / 1000) = 10240 / R_mΩ + * + * = 2048 at the default 5 mΩ (the rev2 operating point, pinned by host + * test). Integer division truncates for non-divisor shunt values — + * a ≤0.05 % calibration bias at worst within the 1–1000 mΩ Kconfig + * range, negligible against the 1 % shunt tolerance. Public static so + * the host tests assert the operating point directly. + * + * @param shuntMilliOhm Shunt resistance in mΩ (Kconfig range-checks + * 1..1000; a raw 0 is clamped defensively — never a division + * by zero). + */ + static constexpr uint16_t calibrationFor(uint32_t shuntMilliOhm) + { + return static_cast( + 10240u / (shuntMilliOhm == 0 ? 1u : shuntMilliOhm)); + } + + /** + * @brief Construct the sensor over an injected I2C bus. + * + * @param bus I2C master used for every transaction; must outlive this + * object. MUST be the shared bus instance (the one the + * BME280 uses) — never a second bus on the same pins + * (bus-sharing contract, FR-008). + * @param address 7-bit device address (BOARD_INA226_ADDR, 0x40 on + * rev2: A0 = A1 = GND). + * @param shuntMilliOhm Shunt resistance in mΩ + * (CONFIG_WS_INA226_SHUNT_MILLIOHM, default 5). + */ + Ina226Sensor(II2cBus& bus, uint8_t address, uint32_t shuntMilliOhm); + + ~Ina226Sensor() override = default; + + Ina226Sensor(const Ina226Sensor&) = delete; + Ina226Sensor& operator=(const Ina226Sensor&) = delete; + + // IPowerSensor + bool initialize() override; + bool read() override; + bool isAvailable() override; + int getLastError() override; + + float getBusVoltage() override; + float getCurrent() override; + float getPower() override; + +private: + // Register map (used subset, data-model.md). 0x01 (shunt voltage) and + // 0x06/0x07 (Mask/Enable, Alert) are unused — the ALERT pin is not + // connected on rev2. + static constexpr uint8_t kRegConfig = 0x00; + static constexpr uint8_t kRegBusVoltage = 0x02; + static constexpr uint8_t kRegPower = 0x03; + static constexpr uint8_t kRegCurrent = 0x04; + static constexpr uint8_t kRegCalibration = 0x05; + static constexpr uint8_t kRegManufacturerId = 0xFE; + static constexpr uint8_t kRegDieId = 0xFF; + + /// Configuration register value: AVG ×16, VBUSCT = VSHCT = 1.1 ms, + /// MODE = continuous shunt + bus. Bit-field derivation at the write + /// site in Ina226Sensor.cpp; asserted byte-exact by host tests. + static constexpr uint16_t kConfigValue = 0x4527; + + /// Probe the address and verify manufacturer + die ID. + bool probeAndIdentify(); + + II2cBus& bus_; + const uint8_t address_; + const uint16_t calibration_; ///< calibrationFor(shunt) from the ctor + bool initialized_ = false; + /// WARN once per consecutive not-found run (lazy re-init retries on + /// every consumer attempt); repeats are demoted to debug. Reset on + /// successful init. Same policy as Bme280Sensor. + bool initFailureLogged_ = false; + int lastError_ = 0; + + // Last-good reading (published only by a fully successful read()). + // NaN until the first successful read — self-announcing for consumers + // that forget to gate on read()/getLastError(); physically plausible + // 0.0 placeholders would silently pass for real readings. + float busVoltage_ = std::numeric_limits::quiet_NaN(); + float current_ = std::numeric_limits::quiet_NaN(); + float power_ = std::numeric_limits::quiet_NaN(); +}; + +#endif /* WATERINGSYSTEM_SENSORS_INA226SENSOR_H */ diff --git a/firmware/components/sensors/include/sensors/LockedPowerSensor.h b/firmware/components/sensors/include/sensors/LockedPowerSensor.h new file mode 100644 index 0000000..87e52d0 --- /dev/null +++ b/firmware/components/sensors/include/sensors/LockedPowerSensor.h @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file LockedPowerSensor.h + * @brief Mutex-serializing IPowerSensor decorator (header-only). + * + * WHY THIS EXISTS: in this PR the power sensor is only reached from the + * diag console REPL task (`power` command), but PR-09's web server and + * PR-11's controller add readers — the wrapper exists from day one per the + * established rule (every cross-task-reachable sensor is wrapped). + * Ina226Sensor itself is deliberately unsynchronized pure C++ + * (host-testable), so its plain float members would race: read() publishes + * three values member-by-member, and a concurrent getter could observe a + * half-published reading (fresh voltage with stale power). This decorator + * wraps an IPowerSensor and takes a mutex around every interface call, + * serializing all access (pattern of LockedEnvironmentalSensor/ + * LockedWaterPump). + * + * Bus-level safety across I2C devices (BME280 + INA226 on the shared bus) + * is provided by the i2c_master driver's per-transaction bus lock — this + * decorator exists for reading-snapshot consistency, not bus safety. + * + * USAGE RULE: once a sensor is wrapped, the underlying sensor must ONLY be + * accessed through the wrapper — every call site (boot wiring, console + * registration, controllers, ...) goes through the LockedPowerSensor, + * never through the wrapped object directly. + * + * SCOPE: this decorator provides PER-CALL atomicity only, not cross-call. + * A read-then-getters sequence spanning multiple calls (read() then + * getBusVoltage()/getCurrent()/getPower()) is NOT protected against an + * interleaving read() from another task in between — another task may + * refresh (or invalidate) the values first. Such sequences need + * higher-level coordination. + * TODO(PR-11): add a consistent-snapshot helper (one locked call returning + * all three values + validity) when a second periodic reader appears — + * same bookkeeping as the other Locked* wrappers' PR-11 notes. + * + * Pure C++ ( is available via pthread on ESP-IDF and on the linux + * preview target), so the decorator is host-testable. + */ + +#ifndef WATERINGSYSTEM_SENSORS_LOCKEDPOWERSENSOR_H +#define WATERINGSYSTEM_SENSORS_LOCKEDPOWERSENSOR_H + +#include + +#include "interfaces/IPowerSensor.h" + +/** + * @brief IPowerSensor decorator that serializes every call with a mutex. + * + * Composition, not inheritance from a concrete sensor: the base class + * stays pure (no locking) and the host tests are unchanged. The wrapped + * sensor must outlive this object. + */ +class LockedPowerSensor : public IPowerSensor { +public: + /// Wrap @p sensor; the wrapped sensor must outlive this object. + explicit LockedPowerSensor(IPowerSensor& sensor) : sensor_(sensor) {} + + LockedPowerSensor(const LockedPowerSensor&) = delete; + LockedPowerSensor& operator=(const LockedPowerSensor&) = delete; + + bool initialize() override + { + std::lock_guard lock(mutex_); + return sensor_.initialize(); + } + + bool read() override + { + std::lock_guard lock(mutex_); + return sensor_.read(); + } + + bool isAvailable() override + { + std::lock_guard lock(mutex_); + return sensor_.isAvailable(); + } + + int getLastError() override + { + std::lock_guard lock(mutex_); + return sensor_.getLastError(); + } + + float getBusVoltage() override + { + std::lock_guard lock(mutex_); + return sensor_.getBusVoltage(); + } + + float getCurrent() override + { + std::lock_guard lock(mutex_); + return sensor_.getCurrent(); + } + + float getPower() override + { + std::lock_guard lock(mutex_); + return sensor_.getPower(); + } + +private: + IPowerSensor& sensor_; + mutable std::mutex mutex_; +}; + +#endif /* WATERINGSYSTEM_SENSORS_LOCKEDPOWERSENSOR_H */ diff --git a/firmware/components/sensors/include/sensors/testing/MockI2cBus.h b/firmware/components/sensors/include/sensors/testing/MockI2cBus.h index 3cc2b5f..e02faa3 100644 --- a/firmware/components/sensors/include/sensors/testing/MockI2cBus.h +++ b/firmware/components/sensors/include/sensors/testing/MockI2cBus.h @@ -14,8 +14,18 @@ * records BIG-ENDIAN into the same per-address byte map (so byte-level * assertions stay valid) and into the call log, and shares the write * outcome queue with the 8-bit writes (one FIFO covers a mixed-width write - * sequence). Never compiled into target builds (only included from test - * code). No IDF includes. + * sequence). + * + * Two register models coexist (feature 006): the BYTE map models + * auto-incrementing byte registers (BME280 — an N-byte burst walks N + * consecutive register addresses), while the WORD overlay + * (setRegister16()) models pointer-addressed 16-bit registers (INA226 — a + * 2-byte read at one pointer returns THAT register's MSB,LSB and never + * spills into the numerically next register, so adjacent word registers + * like 0x02/0x03/0x04 or 0xFE/0xFF do not collide). An exact 2-byte read + * is served from the word overlay when the register is scripted there; + * everything else falls through to the byte map. Never compiled into + * target builds (only included from test code). No IDF includes. */ #ifndef WATERINGSYSTEM_SENSORS_TESTING_MOCKI2CBUS_H @@ -64,8 +74,13 @@ class MockI2cBus : public II2cBus { void addDevice(uint8_t address7) { devices_[address7]; } /// Remove @p address7: probes NACK, reads/writes fail (unplug scenario). - /// The register map is discarded — re-adding yields a fresh device. - void removeDevice(uint8_t address7) { devices_.erase(address7); } + /// The register maps (byte + word) are discarded — re-adding yields a + /// fresh device. + void removeDevice(uint8_t address7) + { + devices_.erase(address7); + wordRegisters_.erase(address7); + } /// Script one register byte on a device (added implicitly if absent). void setRegister(uint8_t address7, uint8_t reg, uint8_t value) @@ -85,6 +100,19 @@ class MockI2cBus : public II2cBus { } } + /** + * @brief Script one 16-bit WORD register (INA226 model — see the file + * comment: pointer-addressed, adjacent word registers never collide). + * + * Served big-endian by readRegisters()/readRegister16() for exact + * 2-byte reads at @p reg. The device is added implicitly if absent. + */ + void setRegister16(uint8_t address7, uint8_t reg, uint16_t value) + { + devices_[address7]; // implicit add, setRegister() convention + wordRegisters_[address7][reg] = value; + } + /** * @brief Queue the outcome of the NEXT readRegisters() call on a * PRESENT device (FIFO; empty queue = success). Queue e.g. @@ -116,6 +144,22 @@ class MockI2cBus : public II2cBus { calls.push_back(call); return false; } + // WORD-overlay hit: an exact 2-byte read of a scripted 16-bit + // register serves that register big-endian (INA226 model — never + // spills into the numerically next register). + if (len == 2) { + const auto words = wordRegisters_.find(address7); + if (words != wordRegisters_.end()) { + const auto word = words->second.find(startReg); + if (word != words->second.end()) { + buf[0] = static_cast(word->second >> 8); + buf[1] = static_cast(word->second & 0xFF); + call.succeeded = true; + calls.push_back(call); + return true; + } + } + } for (size_t i = 0; i < len; ++i) { buf[i] = device->second[static_cast(startReg + i)]; } @@ -154,6 +198,9 @@ class MockI2cBus : public II2cBus { device->second[reg] = static_cast(value >> 8); device->second[static_cast(reg + 1)] = static_cast(value & 0xFF); + // Also into the WORD overlay, so a write-then-read16 round trip is + // coherent for pointer-addressed devices (INA226 config readback). + wordRegisters_[address7][reg] = value; call.succeeded = true; calls.push_back(call); return true; @@ -172,6 +219,9 @@ class MockI2cBus : public II2cBus { } std::map> devices_; + /// Pointer-addressed 16-bit registers (setRegister16(), INA226 model); + /// consulted only for exact 2-byte reads. + std::map> wordRegisters_; std::vector readOutcomes_; std::vector writeOutcomes_; }; diff --git a/firmware/components/sensors/include/sensors/testing/MockLevelSensor.h b/firmware/components/sensors/include/sensors/testing/MockLevelSensor.h new file mode 100644 index 0000000..3ae9a00 --- /dev/null +++ b/firmware/components/sensors/include/sensors/testing/MockLevelSensor.h @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file MockLevelSensor.h + * @brief Scriptable ILevelSensor test double (header-only). + * + * Serves the host tests of level-sensor CONSUMERS — PR-11's watering + * controller composes its reservoir truth table (both-wet / low-only / + * both-dry / the physically invalid low-dry+high-wet, plus the + * sensor-invalid "do not act" row) from two instances of this mock. The + * driver's own settle/debounce/polarity logic is tested against the REAL + * DebouncedLevelSensor via a scripted IDigitalInput — never through this + * mock. Never compiled into target builds (only included from test code). + * No IDF includes. + * + * Consistency helpers from the start (PR-04 lesson, MockEnvironmentalSensor + * pattern): scriptValidState()/scriptInvalid() keep validity and the + * logical state coherent — an invalid sensor never serves a stale + * "water present" (the interface contract: isWaterPresent() is meaningful + * ONLY while isValid(), and returns false when it carries no meaning). + * rawState() is a separate plain scripted field: it is diagnostics-only + * and polarity-dependent, which a board-agnostic mock cannot derive from + * the logical state — no coherence is defined between the two (matching + * the interface, where raw is explicitly undebounced/unmapped). + */ + +#ifndef WATERINGSYSTEM_SENSORS_TESTING_MOCKLEVELSENSOR_H +#define WATERINGSYSTEM_SENSORS_TESTING_MOCKLEVELSENSOR_H + +#include "interfaces/ILevelSensor.h" + +/** + * @brief ILevelSensor serving a scripted validity + logical state, + * instrumented for tests. + * + * Freshly constructed the mock is INVALID (matching the real sensor's + * settle/warm-up start). update() only counts calls — the scripted state + * is stable until the test changes it (a consumer must behave identically + * however many polls it takes). notifyPowerOn() invalidates, mirroring + * the real settle re-arm, and counts calls. + */ +class MockLevelSensor : public ILevelSensor { +public: + // -- Instrumentation ------------------------------------------------------ + + int updateCalls = 0; + int notifyPowerOnCalls = 0; + + // -- Scripted raw state (diagnostics-only, no coherence with logical) ---- + + bool scriptedRawState = false; ///< served by rawState() + + // -- Consistency helpers -------------------------------------------------- + + /// Script a VALID sensor reporting @p present (water at this mark). + void scriptValidState(bool present) + { + valid_ = true; + present_ = present; + } + + /// Script a NOT-YET-VALID sensor (settling/warming up/faulted): the + /// logical state loses meaning and is served as false per the + /// interface contract — never a stale previous value. + void scriptInvalid() + { + valid_ = false; + present_ = false; + } + + // -- ILevelSensor ---------------------------------------------------------- + + void update() override { ++updateCalls; } + + bool isValid() override { return valid_; } + + bool isWaterPresent() override { return valid_ && present_; } + + bool rawState() override { return scriptedRawState; } + + void notifyPowerOn() override + { + ++notifyPowerOnCalls; + // Mirrors the real sensor: a power-on event re-arms settle gating, + // so readings are invalid until the test scripts a new state. + scriptInvalid(); + } + +private: + bool valid_ = false; ///< construction state: not yet valid + bool present_ = false; ///< meaningful only while valid_ +}; + +#endif /* WATERINGSYSTEM_SENSORS_TESTING_MOCKLEVELSENSOR_H */ diff --git a/firmware/components/sensors/src/Ina226Sensor.cpp b/firmware/components/sensors/src/Ina226Sensor.cpp new file mode 100644 index 0000000..abdb5a4 --- /dev/null +++ b/firmware/components/sensors/src/Ina226Sensor.cpp @@ -0,0 +1,232 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file Ina226Sensor.cpp + * @brief INA226 identity/configuration/scaling logic (pure C++). + * + * Uses ESP_LOG only — the log component is simulated on the IDF linux + * preview target, so this file builds and runs in the host test suite + * (same rule as sensors/Bme280Sensor.cpp). On device targets it is built + * only when CONFIG_BOARD_REV2 (sensors/CMakeLists.txt, FR-011). + * + * Architecture is the proven Bme280Sensor pattern (research.md R7): lazy + * (re-)initialization from read()/isAvailable(), identity check at init, + * error codes 1 (not found / identity mismatch) / 2 (communication failure + * after identification), last-good getter values with NaN placeholders, + * and uninitialize-on-bus-error → identity re-probe recovery. + * + * Register field values are derived from the TI INA226 datasheet, SBOS547 + * (Configuration Register 00h, Bus Voltage Register 02h, Power Register + * 03h, Current Register 04h, Calibration Register 05h, Manufacturer ID FEh, + * Die ID FFh). TODO(PR-14): confirm the configuration value against live + * hardware at rev2 bring-up — no INA226 exists on the rev1 bench rig, so + * this driver is host-verified only until then. + */ + +#include "sensors/Ina226Sensor.h" + +#include "esp_log.h" + +static const char *TAG = "ina226"; + +// --------------------------------------------------------------------------- +// Configuration register (00h) value 0x4527, derived from the SBOS547 +// field layout (verify on hardware at PR-14): +// +// bit 15 RST = 0 normal operation (no reset) +// bits 14:12 — = 100 reserved; bit 14 reads 1 at POR (POR value +// 0x4127) and is written back unchanged +// bits 11:9 AVG = 010 averaging: 16 samples +// bits 8:6 VBUSCT = 100 bus-voltage conversion time 1.1 ms +// bits 5:3 VSHCT = 100 shunt-voltage conversion time 1.1 ms +// bits 2:0 MODE = 111 shunt and bus, continuous +// +// 0b0100'0101'0010'0111 = 0x4527 +// cross-check: 0x4000 | (0b010 << 9) | (0b100 << 6) | (0b100 << 3) | 0b111 +// = 0x4000 | 0x0400 | 0x0100 | 0x0020 | 0x0007 = 0x4527 +// +// One averaged result every 16 × (1.1 + 1.1) ms ≈ 35 ms — smooth pump +// telemetry with negligible lag for the on-demand console/API reads this +// PR and PR-09 need (no periodic telemetry task in this PR). +// --------------------------------------------------------------------------- + +Ina226Sensor::Ina226Sensor(II2cBus& bus, uint8_t address, + uint32_t shuntMilliOhm) + : bus_(bus), + address_(address), + calibration_(calibrationFor(shuntMilliOhm)) +{ +} + +bool Ina226Sensor::initialize() +{ + // Idempotent (family convention, Bme280Sensor pattern). + if (initialized_) { + return true; + } + + // No device ACKs at the configured address, or a responding device is + // not an INA226: error 1, "sensor not found". WARN only once per + // consecutive failure run — the lazy re-init retries on every consumer + // attempt and a permanently absent sensor must not flood the log. + // Repeats go to debug; the flag resets on successful initialization. + if (!probeAndIdentify()) { + lastError_ = 1; + if (!initFailureLogged_) { + initFailureLogged_ = true; + ESP_LOGW(TAG, "initialize failed: no INA226 found at 0x%02x", + address_); + } else { + ESP_LOGD(TAG, "initialize failed: no INA226 found at 0x%02x", + address_); + } + return false; + } + + // A device that identified as an INA226 but then fails mid-init is a + // bus/communication failure, not "not found": error 2. The driver + // stays uninitialized, so the next attempt re-probes the identity from + // scratch. Order matters for the host-test byte assertions: config + // first, then calibration (both 16-bit big-endian, one transaction + // each — writeRegister16, the seam extension this feature added). + if (!bus_.writeRegister16(address_, kRegConfig, kConfigValue) || + !bus_.writeRegister16(address_, kRegCalibration, calibration_)) { + lastError_ = 2; + ESP_LOGW(TAG, "initialize failed: config/calibration write error " + "at 0x%02x", address_); + return false; + } + + initialized_ = true; + lastError_ = 0; + // Recovery re-arms the once-per-run not-found WARN (the INFO line + // below announces the recovery itself). + initFailureLogged_ = false; + ESP_LOGI(TAG, "INA226 initialized at 0x%02x (AVG x16, 1.1 ms " + "conversions, continuous shunt+bus, CAL=%u)", + address_, static_cast(calibration_)); + return true; +} + +bool Ina226Sensor::probeAndIdentify() +{ + if (!bus_.probe(address_)) { + return false; + } + // Identity check (FR-009, mirrors the BME280 chip-ID pattern): both + // the manufacturer ID and the die ID must match. A device that ACKs + // but cannot deliver its identity is treated as not-found — it never + // identified, so error 2 ("after identification") does not apply. + uint16_t manufacturer = 0; + uint16_t die = 0; + if (!bus_.readRegister16(address_, kRegManufacturerId, manufacturer) || + !bus_.readRegister16(address_, kRegDieId, die)) { + ESP_LOGW(TAG, "device at 0x%02x ACKed but identity read failed", + address_); + return false; + } + if (manufacturer != kManufacturerId || die != kDieId) { + ESP_LOGW(TAG, "device at 0x%02x is not an INA226 (manufacturer " + "0x%04x, die 0x%04x; expected 0x%04x/0x%04x) — rejected", + address_, static_cast(manufacturer), + static_cast(die), + static_cast(kManufacturerId), + static_cast(kDieId)); + return false; + } + return true; +} + +bool Ina226Sensor::read() +{ + // Lazy (re-)initialization (family convention). A failure here is + // already logged inside initialize() and lastError_ is set there. + if (!initialized_ && !initialize()) { + return false; + } + + // One snapshot: all three registers are fetched BEFORE any value is + // published, so a mid-read failure leaves the last-good triple fully + // intact — never a fresh voltage next to a stale power. The device + // converts continuously (MODE 0b111); these reads return its latest + // completed averaged conversion set. + uint16_t rawBus = 0; + uint16_t rawPower = 0; + uint16_t rawCurrent = 0; + if (!bus_.readRegister16(address_, kRegBusVoltage, rawBus) || + !bus_.readRegister16(address_, kRegPower, rawPower) || + !bus_.readRegister16(address_, kRegCurrent, rawCurrent)) { + // Bus error: error 2 AND back to UNINITIALIZED so the next call + // re-probes the identity (recovery; the last-good getter values + // remain untouched). + lastError_ = 2; + initialized_ = false; + ESP_LOGW(TAG, "read failed: bus error at 0x%02x — sensor lost, " + "will re-probe", address_); + return false; + } + + // Scaling (SBOS547, data-model.md): + // bus voltage: unsigned, fixed 1.25 mV/LSB + // current: SIGNED two's complement × Current_LSB — the int16_t + // cast preserves the sign (reverse current stays + // negative, never wrapped) + // power: unsigned, 25 × Current_LSB per LSB + busVoltage_ = static_cast(rawBus) * kBusVoltageLsbV; + current_ = + static_cast(static_cast(rawCurrent)) * kCurrentLsbA; + power_ = static_cast(rawPower) * kPowerLsbW; + + lastError_ = 0; + return true; +} + +bool Ina226Sensor::isAvailable() +{ + // Lazy initialization doubles as the probe. Note on the error + // contract: the availability PROBE below never touches lastError_, + // but this lazy path delegates to initialize(), which owns its own + // error reporting (1/2 on failure, 0 on success) — the family + // convention the interface contract refers to. + if (!initialized_) { + return initialize(); + } + + // REAL identity read on every call, never cached (family convention — + // a dead sensor must not report alive forever). Does not touch + // lastError_ — read() owns the reading's error state. + uint16_t manufacturer = 0; + uint16_t die = 0; + const bool available = + bus_.readRegister16(address_, kRegManufacturerId, manufacturer) && + bus_.readRegister16(address_, kRegDieId, die) && + manufacturer == kManufacturerId && die == kDieId; + if (!available) { + // Loss detected: back to UNINITIALIZED so the next call re-probes + // (same recovery path as a failed read()). + initialized_ = false; + ESP_LOGW(TAG, "availability probe failed at 0x%02x — sensor lost, " + "will re-probe", address_); + } + return available; +} + +int Ina226Sensor::getLastError() +{ + return lastError_; +} + +float Ina226Sensor::getBusVoltage() +{ + return busVoltage_; +} + +float Ina226Sensor::getCurrent() +{ + return current_; +} + +float Ina226Sensor::getPower() +{ + return power_; +} diff --git a/firmware/main/Kconfig.projbuild b/firmware/main/Kconfig.projbuild index cb5de4d..6ed8726 100644 --- a/firmware/main/Kconfig.projbuild +++ b/firmware/main/Kconfig.projbuild @@ -7,4 +7,17 @@ menu "WateringSystem" config BOARD_REV2 bool "Rev 2 — custom PCB (THVD1426 auto-direction, INA226, CP2102N)" endchoice + + config WS_INA226_SHUNT_MILLIOHM + int "INA226 pump shunt resistance (milliohm)" + depends on BOARD_REV2 + default 5 + range 1 1000 + help + Shunt resistor value on the pump 12 V supply, in milliohms. + The INA226 calibration register is derived from it at boot + (CAL = 0.00512 / (Current_LSB x R_shunt), Current_LSB fixed at + 0.5 mA — 2048 at the default 5 mOhm, rev2 BOM R1 5 mOhm 2512). + Only change this when the fitted shunt changes; the current + resolution is a compile-time driver constant (research.md R9). endmenu diff --git a/firmware/main/app_main.cpp b/firmware/main/app_main.cpp index f309b99..dfbcb0a 100644 --- a/firmware/main/app_main.cpp +++ b/firmware/main/app_main.cpp @@ -2,10 +2,13 @@ * @file app_main.cpp * @brief WateringSystem firmware entry point. * - * The very first action at boot is to force both pump outputs to a safe - * OFF state. This fail-safe MUST stay first in app_main in all future - * phases: pumps are always off after power-on, watchdog reset and OTA - * restart, before any other initialization runs. + * The very first action at boot is to force every pump output that exists + * on this board to a safe OFF state. This fail-safe MUST stay first in + * app_main in all future phases: pumps are always off after power-on, + * watchdog reset and OTA restart, before any other initialization runs. + * The pump set is capability-aware (feature 006): rev1 has two pumps, + * rev2 is a single-pump node (BOARD_HAS_RESERVOIR_PUMP = 0) — the + * invariant "every pump that exists is OFF first" is unchanged. * * Corollary: no translation unit in this firmware may contain non-trivial * static/global constructors — they run before app_main and would execute @@ -39,6 +42,12 @@ #include "sensors/LockedLevelSensor.h" #include "sensors/LockedSoilSensor.h" #include "sensors/ModbusSoilSensor.h" +#if BOARD_HAS_INA226 +// INA226 headers only on equipped boards: Ina226Sensor.cpp is not in the +// rev1 target build at all (sensors/CMakeLists.txt) — FR-011. +#include "sensors/Ina226Sensor.h" +#include "sensors/LockedPowerSensor.h" +#endif #include "storage/LittleFsDataStorage.h" #include "storage/LockedConfigStore.h" #include "storage/LockedDataStorage.h" @@ -51,7 +60,14 @@ static const char *TAG = "app_main"; /** - * @brief Drive both pump GPIOs to a safe OFF state (output, level 0). + * @brief Drive every pump GPIO that exists on this board to a safe OFF + * state (output, level 0). + * + * Capability-aware (feature 006, FR-007): on two-pump boards (rev1) both + * the plant and the reservoir pump are forced off; on single-pump boards + * (BOARD_HAS_RESERVOIR_PUMP == 0, rev2) exactly the plant pump is — the + * reservoir pin does not exist there and any unguarded reference is a + * compile error (board.h enforcement pattern). * * Pumps are switched by N-channel MOSFET gates and are active high, * so level 0 means pump off. @@ -62,9 +78,12 @@ static const char *TAG = "app_main"; */ static void pumps_force_off(void) { + uint64_t pump_mask = 1ULL << BOARD_PIN_MAIN_PUMP; +#if BOARD_HAS_RESERVOIR_PUMP + pump_mask |= 1ULL << BOARD_PIN_RESERVOIR_PUMP; +#endif const gpio_config_t pump_cfg = { - .pin_bit_mask = (1ULL << BOARD_PIN_MAIN_PUMP) | - (1ULL << BOARD_PIN_RESERVOIR_PUMP), + .pin_bit_mask = pump_mask, .mode = GPIO_MODE_OUTPUT, .pull_up_en = GPIO_PULLUP_DISABLE, .pull_down_en = GPIO_PULLDOWN_DISABLE, @@ -78,11 +97,13 @@ static void pumps_force_off(void) ESP_LOGE(TAG, "FATAL: pump fail-safe init failed: %s", esp_err_to_name(err)); abort(); } +#if BOARD_HAS_RESERVOIR_PUMP err = gpio_set_level(static_cast(BOARD_PIN_RESERVOIR_PUMP), 0); if (err != ESP_OK) { ESP_LOGE(TAG, "FATAL: pump fail-safe init failed: %s", esp_err_to_name(err)); abort(); } +#endif err = gpio_config(&pump_cfg); if (err != ESP_OK) { ESP_LOGE(TAG, "FATAL: pump fail-safe init failed: %s", esp_err_to_name(err)); @@ -106,28 +127,36 @@ extern "C" void app_main(void) ESP_LOGI(TAG, "=========================================="); ESP_LOGI(TAG, "Pumps forced OFF (fail-safe boot state)"); - // Pump driver instances. Function-local statics (NOT globals): they are - // constructed here, strictly after pumps_force_off(), so no constructor - // can run ahead of the boot fail-safe. + // Pump driver instances — one per pump that exists on this board + // (BOARD_HAS_RESERVOIR_PUMP, feature 006). Function-local statics (NOT + // globals): they are constructed here, strictly after + // pumps_force_off(), so no constructor can run ahead of the boot + // fail-safe. + // + // Mutex-serializing wrappers: the pumps are touched by two tasks (this + // main loop's update() and the esp_console REPL task's commands), so + // EVERY access from here on goes through the wrappers — never through + // the GpioWaterPump objects directly. static EspTimeProvider time_provider; static GpioWaterPump plant_pump( static_cast(BOARD_PIN_MAIN_PUMP), "plant", time_provider); + static LockedWaterPump plant(plant_pump); +#if BOARD_HAS_RESERVOIR_PUMP static GpioWaterPump reservoir_pump( static_cast(BOARD_PIN_RESERVOIR_PUMP), "reservoir", time_provider); - - // Mutex-serializing wrappers: the pumps are touched by two tasks (this - // main loop's update() and the esp_console REPL task's commands), so - // EVERY access from here on goes through the wrappers — never through - // the GpioWaterPump objects directly. - static LockedWaterPump plant(plant_pump); static LockedWaterPump reservoir(reservoir_pump); +#endif // initialize() re-asserts OFF (glitch-free) before arming the drivers. // Failure here is fatal: a pump whose output state is unknown must not // be left powered (same policy as pumps_force_off above). - if (!plant.initialize() || !reservoir.initialize()) { + bool pumps_armed = plant.initialize(); +#if BOARD_HAS_RESERVOIR_PUMP + pumps_armed = pumps_armed && reservoir.initialize(); +#endif + if (!pumps_armed) { ESP_LOGE(TAG, "FATAL: pump driver initialization failed"); abort(); } @@ -224,6 +253,31 @@ extern "C" void app_main(void) env_sensor.getLastError()); } +#if BOARD_HAS_INA226 + // INA226 pump power monitor (feature 006, rev2 only). Rides the SAME + // EspI2cBus instance as the BME280 — the bus-sharing contract from + // PR-03: one bus owner, every I2C driver receives it, never a second + // bus on these pins. Not safety-critical: a failed init is logged and + // the system keeps running — lazy re-init recovers on later attempts + // (US3 semantics). Function-local statics after pumps_force_off() + // (boot fail-safe rule). Wrapped in the mutex-serializing decorator: + // reached from the console REPL task only in this PR, but wrapped + // already per the established rule (PR-09 web + PR-11 controller add + // readers), so EVERY access goes through the wrapper. + static Ina226Sensor power_sensor_raw(i2c_bus, BOARD_INA226_ADDR, + CONFIG_WS_INA226_SHUNT_MILLIOHM); + static LockedPowerSensor power_sensor(power_sensor_raw); + + if (power_sensor.initialize()) { + ESP_LOGI(TAG, "INA226 power monitor up at 0x%02x (shunt %d mOhm)", + BOARD_INA226_ADDR, CONFIG_WS_INA226_SHUNT_MILLIOHM); + } else { + ESP_LOGW(TAG, "INA226 init failed (error %d) — power readings " + "unavailable until recovery", + power_sensor.getLastError()); + } +#endif + // Reservoir level sensors (feature 006). Not safety-critical at boot: // a failed GPIO init is logged and the system keeps running — the // sensors report not-yet-valid/garbage-gated states and PR-11's @@ -258,11 +312,21 @@ extern "C" void app_main(void) level_high.notifyPowerOn(); // Serial diagnostic REPL (rig testing; contracts/serial-diagnostic.md). + // Pump registration is capability-aware: single-pump boards register + // exactly the plant pump — `pump reservoir` does not exist there + // (compile-time absence, PR-14 contract). +#if BOARD_HAS_RESERVOIR_PUMP diag_console_register_pumps(plant, reservoir); +#else + diag_console_register_pumps(plant); +#endif diag_console_register_storage(config, storage); diag_console_register_soil(soil_sensor, modbus_client); diag_console_register_env(env_sensor); diag_console_register_level(level_low, level_high); +#if BOARD_HAS_INA226 + diag_console_register_power(power_sensor); +#endif esp_err_t err = diag_console_start(); if (err != ESP_OK) { // Console is a diagnostic aid, not a safety function: log and keep @@ -275,13 +339,16 @@ extern "C" void app_main(void) // sensor failed init above — lazy re-init recovers later (parity). sensor_task_start(env_sensor); - // Main loop: poll pump enforcement and the level sensors at 10 Hz. - // Pump update() applies the timed self-stop and the hard 300 s - // max-runtime cap; level update() samples the raw pins and advances - // the settle/debounce state machines (~3 samples per 300 ms window). + // Main loop: poll pump enforcement (every pump that exists on this + // board) and the level sensors at 10 Hz. Pump update() applies the + // timed self-stop and the hard 300 s max-runtime cap; level update() + // samples the raw pins and advances the settle/debounce state + // machines (~3 samples per 300 ms window). while (true) { plant.update(); +#if BOARD_HAS_RESERVOIR_PUMP reservoir.update(); +#endif level_low.update(); level_high.update(); vTaskDelay(pdMS_TO_TICKS(100)); diff --git a/firmware/main/diag_console.cpp b/firmware/main/diag_console.cpp index b815d68..a649ba8 100644 --- a/firmware/main/diag_console.cpp +++ b/firmware/main/diag_console.cpp @@ -10,7 +10,12 @@ * pump start # timed run; 1..300 * pump stop * pump status - * pump status # both pumps + * pump status # every existing pump + * + * The pump set is capability-aware (feature 006, FR-007): on boards with + * BOARD_HAS_RESERVOIR_PUMP == 0 (rev2, single-pump node) the reservoir + * slot is compiled out — `pump reservoir ...` is a usage error and + * `pump status` reports exactly one pump (PR-14 contract). * * Storage commands (HIL verification path for feature 003, quickstart.md * steps 2-4; thin wrappers — every handler is a direct interface call): @@ -52,6 +57,12 @@ * * level # both sensors: logical + raw + validity * + * Power telemetry command (feature 006, BOARD_HAS_INA226 builds only; the + * PR-14 bring-up path — the failure output distinguishes error 1, sensor + * not found, from error 2, read failed): + * + * power # one read(); V/I/P or ERROR + * * Handler exit codes follow the esp_console convention: 0 on OK, 1 on ERR. * * State is plain pointers/PODs set from app_main — no non-trivial static @@ -71,11 +82,13 @@ #include "esp_console.h" +#include "board/board.h" #include "interfaces/IConfigStore.h" #include "interfaces/IDataStorage.h" #include "interfaces/IEnvironmentalSensor.h" #include "interfaces/ILevelSensor.h" #include "interfaces/IModbusClient.h" +#include "interfaces/IPowerSensor.h" #include "interfaces/ISoilSensor.h" #include "interfaces/IWaterPump.h" @@ -89,12 +102,28 @@ struct PumpSlot { int lastStartedDurationS; }; -// Trivially initialized — safe before app_main (no static constructors). -PumpSlot s_slots[2] = { +// One slot per pump that EXISTS on this board (feature 006 capability +// flag): single-pump boards compile the reservoir slot out entirely, so +// `pump reservoir ...` is rejected as usage (the name matches no slot) and +// `pump status` iterates exactly the existing pumps. Trivially +// initialized — safe before app_main (no static constructors). +PumpSlot s_slots[] = { {"plant", nullptr, 0}, +#if BOARD_HAS_RESERVOIR_PUMP {"reservoir", nullptr, 0}, +#endif }; +/// Pump command grammar for this board (usage + help text stay truthful: +/// a rev2 operator is never offered a `reservoir` word that cannot exist). +#if BOARD_HAS_RESERVOIR_PUMP +constexpr char kPumpHelp[] = + "pump |stop|status> | pump status"; +#else +constexpr char kPumpHelp[] = + "pump |stop|status> | pump status"; +#endif + // Storage instances (set from app_main; expected to be the Locked* // decorators — the handlers run on the REPL task, FR-013). Trivially // initialized pointers, same rule as s_slots. @@ -116,6 +145,13 @@ IEnvironmentalSensor *s_env = nullptr; ILevelSensor *s_level_low = nullptr; ILevelSensor *s_level_high = nullptr; +#if BOARD_HAS_INA226 +// Power sensor (set from app_main; expected to be the LockedPowerSensor +// decorator). Same trivial-initialization rule. Compiled out together +// with the `power` command on boards without an INA226. +IPowerSensor *s_power = nullptr; +#endif + const char *stop_reason_str(StopReason reason) { switch (reason) { @@ -147,8 +183,7 @@ void print_status(const PumpSlot &slot) int print_usage(void) { - printf("ERR usage: pump |stop|status> " - "| pump status\n"); + printf("ERR usage: %s\n", kPumpHelp); return 1; } @@ -697,13 +732,63 @@ int level_cmd(int argc, char **argv) return 0; } +#if BOARD_HAS_INA226 + +// --- power command (feature 006, PR-14 bring-up path) --------------------- + +/// `power`: one read() through the locked sensor; thin wrapper, no logic. +/// Failure output is the binding contract format `ERROR ` with a +/// hint distinguishing error 1 (sensor not found — no ACK at 0x40 or a +/// foreign device failed the identity check) from error 2 (read failed — +/// device identified but communication broke). +int power_cmd(int argc, char **argv) +{ + (void)argv; + if (argc != 1) { + printf("ERR usage: power\n"); + return 1; + } + if (s_power == nullptr) { + printf("ERR power sensor not available\n"); + return 1; + } + if (!s_power->read()) { + // read() and getLastError() are separate locked calls; when + // PR-09/PR-11 add readers an interleaving read can reset the + // error to 0 between them (benign cross-lock race, TODO(PR-11) + // snapshot helper in LockedPowerSensor.h) — hint accordingly. + const int error = s_power->getLastError(); + const char *hint = (error == 1) ? "sensor not found" + : (error == 2) ? "read failed" + : (error == 0) + ? "state changed concurrently - retry" + : "unknown error"; + printf("ERROR %d (%s)\n", error, hint); + return 1; + } + printf("OK bus=%.3f V current=%.3f A power=%.3f W\n", + static_cast(s_power->getBusVoltage()), + static_cast(s_power->getCurrent()), + static_cast(s_power->getPower())); + return 0; +} + +#endif // BOARD_HAS_INA226 + } // namespace +#if BOARD_HAS_RESERVOIR_PUMP void diag_console_register_pumps(IWaterPump& plant, IWaterPump& reservoir) { s_slots[0].pump = &plant; s_slots[1].pump = &reservoir; } +#else +void diag_console_register_pumps(IWaterPump& plant) +{ + s_slots[0].pump = &plant; +} +#endif void diag_console_register_storage(IConfigStore& config, IDataStorage& storage) { @@ -728,6 +813,13 @@ void diag_console_register_level(ILevelSensor& low, ILevelSensor& high) s_level_high = &high; } +#if BOARD_HAS_INA226 +void diag_console_register_power(IPowerSensor& sensor) +{ + s_power = &sensor; +} +#endif + esp_err_t diag_console_start(void) { esp_console_repl_t *repl = nullptr; @@ -745,8 +837,7 @@ esp_err_t diag_console_start(void) const esp_console_cmd_t cmd = { .command = "pump", - .help = "pump |stop|status> " - "| pump status", + .help = kPumpHelp, .hint = nullptr, .func = &pump_cmd, .argtable = nullptr, @@ -890,5 +981,22 @@ esp_err_t diag_console_start(void) return err; } +#if BOARD_HAS_INA226 + const esp_console_cmd_t cmd_power = { + .command = "power", + .help = "power — one INA226 read (bus V / current A / power W or " + "error code)", + .hint = nullptr, + .func = &power_cmd, + .argtable = nullptr, + .func_w_context = nullptr, + .context = nullptr, + }; + err = esp_console_cmd_register(&cmd_power); + if (err != ESP_OK) { + return err; + } +#endif + return esp_console_start_repl(repl); } diff --git a/firmware/main/diag_console.h b/firmware/main/diag_console.h index 1f5e1cb..0c66ce7 100644 --- a/firmware/main/diag_console.h +++ b/firmware/main/diag_console.h @@ -11,22 +11,34 @@ #ifndef WATERINGSYSTEM_MAIN_DIAG_CONSOLE_H #define WATERINGSYSTEM_MAIN_DIAG_CONSOLE_H +#include "board/board.h" #include "esp_err.h" #include "interfaces/IConfigStore.h" #include "interfaces/IDataStorage.h" #include "interfaces/IEnvironmentalSensor.h" #include "interfaces/ILevelSensor.h" #include "interfaces/IModbusClient.h" +#include "interfaces/IPowerSensor.h" #include "interfaces/ISoilSensor.h" #include "interfaces/IWaterPump.h" /** * @brief Register the pump instances the console commands operate on. * + * Capability-aware signature (feature 006, FR-007): on single-pump boards + * (BOARD_HAS_RESERVOIR_PUMP == 0) only the plant pump is registered and + * `pump reservoir ...` does not exist — compile-time absence, so PR-14's + * "exactly one pump" check sees a usage error, never a runtime + * "unavailable". + * * Must be called before diag_console_start(). Plain pointer registration — * no static constructors involved (boot fail-safe rule). */ +#if BOARD_HAS_RESERVOIR_PUMP void diag_console_register_pumps(IWaterPump& plant, IWaterPump& reservoir); +#else +void diag_console_register_pumps(IWaterPump& plant); +#endif /** * @brief Register the storage instances the `config`/`storage` commands @@ -76,6 +88,20 @@ void diag_console_register_env(IEnvironmentalSensor& sensor); */ void diag_console_register_level(ILevelSensor& low, ILevelSensor& high); +#if BOARD_HAS_INA226 +/** + * @brief Register the power sensor the `power` command operates on + * (bring-up path for PR-14; feature 006). + * + * Only exists on INA226-equipped boards (rev2) — the `power` command is + * compiled out elsewhere. Pass the LockedPowerSensor decorator, never the + * raw sensor — the console handler runs on the REPL task, and PR-09/PR-11 + * add further readers. Must be called before diag_console_start(); plain + * pointer registration. + */ +void diag_console_register_power(IPowerSensor& sensor); +#endif + /** * @brief Start the UART REPL (prompt "ws>") and register the commands. * diff --git a/firmware/test_apps/host/main/CMakeLists.txt b/firmware/test_apps/host/main/CMakeLists.txt index 48be977..a0fc93b 100644 --- a/firmware/test_apps/host/main/CMakeLists.txt +++ b/firmware/test_apps/host/main/CMakeLists.txt @@ -7,5 +7,10 @@ idf_component_register( "test_bme280.cpp" "test_level_sensor.cpp" "test_ina226.cpp" - REQUIRES unity actuators interfaces storage nvs_flash sensors + # Compile-time board-contract TUs (T016): each defines one board + # selector before including the REAL board/board.h — passing = + # compiling (static_asserts only, nothing registers with Unity). + "test_board_contract_rev1.cpp" + "test_board_contract_rev2.cpp" + REQUIRES unity actuators interfaces storage nvs_flash sensors board ) diff --git a/firmware/test_apps/host/main/test_board_contract_rev1.cpp b/firmware/test_apps/host/main/test_board_contract_rev1.cpp new file mode 100644 index 0000000..c41bbef --- /dev/null +++ b/firmware/test_apps/host/main/test_board_contract_rev1.cpp @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file test_board_contract_rev1.cpp + * @brief Compile-time capability contract of the REAL rev1 board header + * (tasks.md T016, feature 006). + * + * The host test app defines no board Kconfig choice, so CONFIG_BOARD_* is + * absent from its sdkconfig.h — defining the rev1 selector before + * including board/board.h compiles the REAL rev1 profile (not a copy) + * into this translation unit. Its sibling + * (test_board_contract_rev2.cpp) does the same for rev2, so the host + * build asserts the board-header contract "both ways" without either + * device toolchain. + * + * Passing = compiling: every check is a static_assert or preprocessor + * #error — there is nothing to run. The AUTHORITATIVE verification of the + * capability gating remains the dual-target CI build (quickstart.md §2, + * SC-001): an unguarded BOARD_PIN_RESERVOIR_PUMP reference in app code + * only fails THERE. This TU pins the header-level contract those builds + * rely on. + */ + +#define CONFIG_BOARD_REV1_DEVKIT 1 +#include "board/board.h" + +// 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, + "rev1 board contract: two-pump node (capability flag set)"); +#ifndef BOARD_PIN_RESERVOIR_PUMP +#error "rev1 board contract: BOARD_PIN_RESERVOIR_PUMP must be defined" +#endif +static_assert(BOARD_PIN_RESERVOIR_PUMP == 27, + "rev1 board contract: reservoir pump pin (parity, " + "src/main.cpp legacy pin table)"); + +// rev1 has no INA226 — neither the flag nor the address (flag=0 ⇒ address +// undefined, the RS485-DE enforcement pattern). +static_assert(BOARD_HAS_INA226 == 0, + "rev1 board contract: no INA226 on the devkit rig"); +#ifdef BOARD_INA226_ADDR +#error "rev1 board contract: BOARD_INA226_ADDR must NOT be defined" +#endif + +// Level sensors: rev1 polarity is active HIGH (FW-5, non-inverting +// TXS0108E path) with no settle gating (permanently powered rail). +static_assert(BOARD_PIN_LEVEL_LOW == 32 && BOARD_PIN_LEVEL_HIGH == 33, + "rev1 board contract: level pins (parity checklist line 95)"); +static_assert(BOARD_LEVEL_ACTIVE_LOW == 0, + "rev1 board contract: active HIGH (FW-5)"); +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"); diff --git a/firmware/test_apps/host/main/test_board_contract_rev2.cpp b/firmware/test_apps/host/main/test_board_contract_rev2.cpp new file mode 100644 index 0000000..9ea1284 --- /dev/null +++ b/firmware/test_apps/host/main/test_board_contract_rev2.cpp @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file test_board_contract_rev2.cpp + * @brief Compile-time capability contract of the REAL rev2 board header + * (tasks.md T016, feature 006). + * + * Companion of test_board_contract_rev1.cpp (mechanism documented there): + * this TU compiles the real rev2 profile and pins the SINGLE-PUMP side of + * the capability contract — most importantly that the reservoir pump pin + * does not exist, so any unguarded reference anywhere is a compile error + * (master PRD FR4 single-pump decision, spec 006 FR-006). + */ + +#define CONFIG_BOARD_REV2 1 +#include "board/board.h" + +// 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). +static_assert(BOARD_HAS_RESERVOIR_PUMP == 0, + "rev2 board contract: single-pump node (master PRD FR4)"); +#ifdef BOARD_PIN_RESERVOIR_PUMP +#error "rev2 board contract: BOARD_PIN_RESERVOIR_PUMP must NOT be defined \ +(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). +static_assert(BOARD_HAS_INA226 == 1, + "rev2 board contract: INA226 pump monitor present"); +#ifndef BOARD_INA226_ADDR +#error "rev2 board contract: BOARD_INA226_ADDR must be defined" +#endif +static_assert(BOARD_INA226_ADDR == 0x40, + "rev2 board contract: pump INA226 at 0x40"); + +// Level sensors: rev2 polarity is active LOW (FW-5, 2N7002 inverter) with +// the FW-3 settle gate for the switched sensor rail. +static_assert(BOARD_LEVEL_ACTIVE_LOW == 1, + "rev2 board contract: active LOW (FW-5, 2N7002 inverter)"); +static_assert(BOARD_LEVEL_SETTLE_MS == 500, + "rev2 board contract: 500 ms settle gate (FW-3)"); +static_assert(BOARD_LEVEL_DEBOUNCE_MS == 300, + "rev2 board contract: 300 ms debounce window"); + +// No RS485 direction pin either (THVD1426 auto-direction) — the pattern +// the reservoir-pump enforcement was cloned from stays intact. +static_assert(BOARD_HAS_RS485_DE == 0, + "rev2 board contract: auto-direction RS485, no DE pin"); +#ifdef BOARD_PIN_RS485_DE +#error "rev2 board contract: BOARD_PIN_RS485_DE must NOT be defined" +#endif diff --git a/firmware/test_apps/host/main/test_ina226.cpp b/firmware/test_apps/host/main/test_ina226.cpp index 9e16255..9676b68 100644 --- a/firmware/test_apps/host/main/test_ina226.cpp +++ b/firmware/test_apps/host/main/test_ina226.cpp @@ -8,26 +8,45 @@ * (test_main.cpp); the process exit code equals the failure count and is * the CI gate. * - * This file currently covers tasks.md T006: the II2cBus 16-bit extension - * at the mock level — writeRegister16() recording big-endian into the - * per-address byte map + call log (byte-level assertions stay valid), the - * readRegister16() big-endian helper (non-virtual, over readRegisters()), - * outcome scripting shared across write widths, and the uint8_t register - * index wrap convention. The Ina226Sensor driver tests (T018: scaling - * vectors, identity, error paths) are added to this suite by US3. + * Coverage: tasks.md T006 — the II2cBus 16-bit extension at the mock + * level: writeRegister16() recording big-endian into the per-address byte + * map + call log (byte-level assertions stay valid), the readRegister16() + * big-endian helper (non-virtual, over readRegisters()), outcome scripting + * shared across write widths, the uint8_t register index wrap convention, + * and the word-register overlay (setRegister16(), pointer-addressed INA226 + * model — adjacent 16-bit registers never collide). Tasks.md T018 — the + * REAL Ina226Sensor driver over MockI2cBus: config/calibration write bytes + * asserted big-endian in order, the 5 mΩ/0.5 mA operating point + * (CAL = 2048), scaling vectors hand-computed from the SBOS547 formulas + * (LSB units, a realistic pump vector, a negative-current vector), + * identity mismatch / absent device → error 1, mid-read bus error → + * error 2 + last-good retention + re-probe recovery, mid-init write + * failure → error 2, live isAvailable() probe, and the LockedPowerSensor + * delegation check. */ +#include #include #include "unity.h" +#include "sensors/Ina226Sensor.h" +#include "sensors/LockedPowerSensor.h" #include "sensors/testing/MockI2cBus.h" namespace { constexpr uint8_t kAddr = 0x40; ///< rev2 pump INA226 (A0 = A1 = GND) constexpr uint8_t kRegConfig = 0x00; +constexpr uint8_t kRegBusVoltage = 0x02; +constexpr uint8_t kRegPower = 0x03; +constexpr uint8_t kRegCurrent = 0x04; constexpr uint8_t kRegCalibration = 0x05; +constexpr uint8_t kRegManufacturerId = 0xFE; +constexpr uint8_t kRegDieId = 0xFF; + +/// Default shunt (rev2 BOM R1, Kconfig default): 5 mΩ. +constexpr uint32_t kShuntMilliOhm = 5; // --------------------------------------------------------------------------- // writeRegister16: big-endian into the byte map + call log (T006) @@ -161,6 +180,350 @@ void test_write16_wraps_register_index(void) TEST_ASSERT_EQUAL_HEX8(0x34, lsb); } +// --------------------------------------------------------------------------- +// Word-register overlay: pointer-addressed 16-bit registers (T018 enabler) +// --------------------------------------------------------------------------- + +void test_mock_word_registers_adjacent_no_collision(void) +{ + MockI2cBus bus; + // The INA226 data registers are ADJACENT pointer addresses; in the + // byte-map model their MSB/LSB pairs would overwrite each other + // (0x02's LSB slot is 0x03's MSB slot). The word overlay keeps each + // 16-bit register independent — the model real hardware implements. + bus.setRegister16(kAddr, kRegBusVoltage, 0x2580); + bus.setRegister16(kAddr, kRegPower, 0x0F00); + bus.setRegister16(kAddr, kRegCurrent, 0x1F40); + + uint16_t value = 0; + TEST_ASSERT_TRUE(bus.readRegister16(kAddr, kRegBusVoltage, value)); + TEST_ASSERT_EQUAL_HEX16(0x2580, value); + TEST_ASSERT_TRUE(bus.readRegister16(kAddr, kRegPower, value)); + TEST_ASSERT_EQUAL_HEX16(0x0F00, value); + TEST_ASSERT_TRUE(bus.readRegister16(kAddr, kRegCurrent, value)); + TEST_ASSERT_EQUAL_HEX16(0x1F40, value); +} + +// --------------------------------------------------------------------------- +// Ina226Sensor driver (T018). Scripting helpers: a healthy INA226 answers +// its identity registers (0xFE = 0x5449 "TI", 0xFF = 0x2260) and serves the +// three data registers via the word overlay. +// --------------------------------------------------------------------------- + +void script_identity(MockI2cBus& bus) +{ + bus.addDevice(kAddr); + bus.setRegister16(kAddr, kRegManufacturerId, 0x5449); + bus.setRegister16(kAddr, kRegDieId, 0x2260); +} + +void script_readings(MockI2cBus& bus, uint16_t rawBus, uint16_t rawPower, + uint16_t rawCurrent) +{ + bus.setRegister16(kAddr, kRegBusVoltage, rawBus); + bus.setRegister16(kAddr, kRegPower, rawPower); + bus.setRegister16(kAddr, kRegCurrent, rawCurrent); +} + +// --- initialization: write sequence + operating point --------------------- + +void test_ina226_calibration_operating_point(void) +{ + // CAL = 0.00512 / (Current_LSB × R_shunt), Current_LSB = 0.5 mA: + // 5 mΩ: 0.00512 / (0.0005 A × 0.005 Ω) = 0.00512 / 2.5e-6 = 2048 + // 10 mΩ: 0.00512 / (0.0005 A × 0.010 Ω) = 1024 + // 1 mΩ: 10240 (range floor — still fits u16) + // 1000 mΩ: 10.24 → 10 (range ceiling, integer truncation) + TEST_ASSERT_EQUAL_UINT16(2048, Ina226Sensor::calibrationFor(5)); + TEST_ASSERT_EQUAL_UINT16(1024, Ina226Sensor::calibrationFor(10)); + TEST_ASSERT_EQUAL_UINT16(10240, Ina226Sensor::calibrationFor(1)); + TEST_ASSERT_EQUAL_UINT16(10, Ina226Sensor::calibrationFor(1000)); +} + +void test_ina226_initialize_writes_config_then_calibration(void) +{ + MockI2cBus bus; + script_identity(bus); + Ina226Sensor sensor(bus, kAddr, kShuntMilliOhm); + + TEST_ASSERT_TRUE(sensor.initialize()); + TEST_ASSERT_EQUAL_INT(0, sensor.getLastError()); + + // Exactly two 16-bit writes, config BEFORE calibration: + // + // config (0x00) = 0x4527, derived from the SBOS547 field layout: + // [15] RST=0 | [14:12] reserved=100 (bit 14 is 1 at POR, kept) | + // [11:9] AVG=010 (16 samples) | [8:6] VBUSCT=100 (1.1 ms) | + // [5:3] VSHCT=100 (1.1 ms) | [2:0] MODE=111 (continuous shunt+bus) + // → 0b0100'0101'0010'0111 = 0x4527 + // calibration (0x05) = 2048 = 0x0800 (5 mΩ operating point above) + int write16Count = 0; + for (const MockI2cBus::Call& call : bus.calls) { + if (call.type != MockI2cBus::Call::Type::Write16) { + continue; + } + ++write16Count; + if (write16Count == 1) { + TEST_ASSERT_EQUAL_HEX8(kRegConfig, call.reg); + TEST_ASSERT_EQUAL_HEX16(0x4527, call.value16); + } else if (write16Count == 2) { + TEST_ASSERT_EQUAL_HEX8(kRegCalibration, call.reg); + TEST_ASSERT_EQUAL_HEX16(0x0800, call.value16); + } + } + TEST_ASSERT_EQUAL_INT(2, write16Count); + + // Byte-level big-endian view (the wire order): MSB first in the byte + // map for both writes. + uint8_t raw = 0; + TEST_ASSERT_TRUE(bus.readRegisters(kAddr, kRegConfig, &raw, 1)); + TEST_ASSERT_EQUAL_HEX8(0x45, raw); + TEST_ASSERT_TRUE(bus.readRegisters(kAddr, kRegConfig + 1, &raw, 1)); + TEST_ASSERT_EQUAL_HEX8(0x27, raw); + TEST_ASSERT_TRUE(bus.readRegisters(kAddr, kRegCalibration, &raw, 1)); + TEST_ASSERT_EQUAL_HEX8(0x08, raw); + TEST_ASSERT_TRUE(bus.readRegisters(kAddr, kRegCalibration + 1, &raw, 1)); + TEST_ASSERT_EQUAL_HEX8(0x00, raw); +} + +// --- scaling vectors (hand-computed from the SBOS547 formulas) ------------ + +void test_ina226_getters_nan_before_first_read(void) +{ + MockI2cBus bus; + script_identity(bus); + Ina226Sensor sensor(bus, kAddr, kShuntMilliOhm); + TEST_ASSERT_TRUE(sensor.initialize()); + + // Initialized but never read: the placeholders are self-announcing + // NaN, never a plausible 0.0 (interface contract). + TEST_ASSERT_TRUE(std::isnan(sensor.getBusVoltage())); + TEST_ASSERT_TRUE(std::isnan(sensor.getCurrent())); + TEST_ASSERT_TRUE(std::isnan(sensor.getPower())); +} + +void test_ina226_read_scales_lsb_units(void) +{ + MockI2cBus bus; + script_identity(bus); + // Raw 1 in every register pins the LSB weights themselves: + // bus: 1 × 1.25 mV = 0.00125 V + // power: 1 × 25 × 0.5 mA = 12.5 mW = 0.0125 W + // current: 1 × 0.5 mA = 0.0005 A + script_readings(bus, 1, 1, 1); + Ina226Sensor sensor(bus, kAddr, kShuntMilliOhm); + + TEST_ASSERT_TRUE(sensor.read()); + TEST_ASSERT_FLOAT_WITHIN(1e-6f, 0.00125f, sensor.getBusVoltage()); + TEST_ASSERT_FLOAT_WITHIN(1e-7f, 0.0005f, sensor.getCurrent()); + TEST_ASSERT_FLOAT_WITHIN(1e-6f, 0.0125f, sensor.getPower()); +} + +void test_ina226_read_scales_pump_vector(void) +{ + MockI2cBus bus; + script_identity(bus); + // Realistic rev2 pump operating point (~12 V, ~4 A): + // bus: 12.000 V / 1.25 mV = 9600 = 0x2580 → 9600 × 0.00125 + // = 12.000 V + // current: 4.000 A / 0.5 mA = 8000 = 0x1F40 → 8000 × 0.0005 + // = 4.000 A + // power: 48.000 W / 12.5 mW = 3840 = 0x0F00 → 3840 × 0.0125 + // = 48.000 W + // (coherent: 12 V × 4 A = 48 W — the device computes power from + // current × busV internally; the script mirrors that) + script_readings(bus, 0x2580, 0x0F00, 0x1F40); + Ina226Sensor sensor(bus, kAddr, kShuntMilliOhm); + + TEST_ASSERT_TRUE(sensor.read()); + TEST_ASSERT_EQUAL_INT(0, sensor.getLastError()); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 12.0f, sensor.getBusVoltage()); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 4.0f, sensor.getCurrent()); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 48.0f, sensor.getPower()); +} + +void test_ina226_negative_current_sign_preserved(void) +{ + MockI2cBus bus; + script_identity(bus); + // The current register is SIGNED two's complement (SBOS547): raw + // 0xE0C0 = 57536 unsigned; as int16: 57536 − 65536 = −8000 → + // −8000 × 0.5 mA = −4.000 A. The sign must survive — never wrapped to + // +28.768 A (57536 × 0.5 mA). Bus voltage and power registers stay + // unsigned. + script_readings(bus, 0x2580, 0x0F00, 0xE0C0); + Ina226Sensor sensor(bus, kAddr, kShuntMilliOhm); + + TEST_ASSERT_TRUE(sensor.read()); + TEST_ASSERT_FLOAT_WITHIN(0.001f, -4.0f, sensor.getCurrent()); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 12.0f, sensor.getBusVoltage()); +} + +// --- error paths: identity, absence, bus errors, recovery ----------------- + +void test_ina226_identity_mismatch_is_error_1(void) +{ + MockI2cBus bus; + // A device ACKs at 0x40 but is not an INA226: right manufacturer, + // wrong die ID (e.g. a future pin-compatible part) — rejected with + // error 1, and the config/calibration writes never happen. + bus.addDevice(kAddr); + bus.setRegister16(kAddr, kRegManufacturerId, 0x5449); + bus.setRegister16(kAddr, kRegDieId, 0x2261); + Ina226Sensor sensor(bus, kAddr, kShuntMilliOhm); + + TEST_ASSERT_FALSE(sensor.initialize()); + TEST_ASSERT_EQUAL_INT(1, sensor.getLastError()); + for (const MockI2cBus::Call& call : bus.calls) { + TEST_ASSERT_TRUE(call.type != MockI2cBus::Call::Type::Write16); + } + + // Foreign manufacturer entirely (correct die by coincidence): same + // rejection. + MockI2cBus bus2; + bus2.addDevice(kAddr); + bus2.setRegister16(kAddr, kRegManufacturerId, 0x5448); + bus2.setRegister16(kAddr, kRegDieId, 0x2260); + Ina226Sensor sensor2(bus2, kAddr, kShuntMilliOhm); + TEST_ASSERT_FALSE(sensor2.initialize()); + TEST_ASSERT_EQUAL_INT(1, sensor2.getLastError()); +} + +void test_ina226_absent_device_is_error_1(void) +{ + MockI2cBus bus; // nothing at 0x40 — address NACK + Ina226Sensor sensor(bus, kAddr, kShuntMilliOhm); + + TEST_ASSERT_FALSE(sensor.initialize()); + TEST_ASSERT_EQUAL_INT(1, sensor.getLastError()); + + // read() lazy-inits and fails the same way; placeholders stay NaN. + TEST_ASSERT_FALSE(sensor.read()); + TEST_ASSERT_EQUAL_INT(1, sensor.getLastError()); + TEST_ASSERT_TRUE(std::isnan(sensor.getBusVoltage())); + TEST_ASSERT_TRUE(std::isnan(sensor.getCurrent())); + TEST_ASSERT_TRUE(std::isnan(sensor.getPower())); +} + +void test_ina226_mid_init_write_failure_is_error_2(void) +{ + MockI2cBus bus; + script_identity(bus); + Ina226Sensor sensor(bus, kAddr, kShuntMilliOhm); + + // The device identified, then the config write fails: error 2 (a + // communication failure AFTER identification, distinct from error 1), + // and the driver stays uninitialized. + bus.queueWriteOutcome(false); + TEST_ASSERT_FALSE(sensor.initialize()); + TEST_ASSERT_EQUAL_INT(2, sensor.getLastError()); + + // The next attempt re-probes the identity from scratch and succeeds + // (write queue exhausted = success). + const size_t probesBefore = [&] { + size_t n = 0; + for (const MockI2cBus::Call& call : bus.calls) { + n += call.type == MockI2cBus::Call::Type::Probe ? 1 : 0; + } + return n; + }(); + TEST_ASSERT_TRUE(sensor.initialize()); + TEST_ASSERT_EQUAL_INT(0, sensor.getLastError()); + size_t probesAfter = 0; + for (const MockI2cBus::Call& call : bus.calls) { + probesAfter += call.type == MockI2cBus::Call::Type::Probe ? 1 : 0; + } + TEST_ASSERT_EQUAL(probesBefore + 1, probesAfter); +} + +void test_ina226_mid_read_bus_error_lastgood_and_recovery(void) +{ + MockI2cBus bus; + script_identity(bus); + script_readings(bus, 0x2580, 0x0F00, 0x1F40); // 12 V / 48 W / 4 A + Ina226Sensor sensor(bus, kAddr, kShuntMilliOhm); + TEST_ASSERT_TRUE(sensor.read()); + + // Mid-read bus error on a PRESENT device: error 2, the last-good + // triple is untouched, and the driver drops to uninitialized. + bus.queueReadOutcome(false); + TEST_ASSERT_FALSE(sensor.read()); + TEST_ASSERT_EQUAL_INT(2, sensor.getLastError()); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 12.0f, sensor.getBusVoltage()); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 4.0f, sensor.getCurrent()); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 48.0f, sensor.getPower()); + + // Recovery: the next read() re-probes the identity (init sequence + // reruns — config + calibration written again) and delivers fresh + // values. New operating point: 12.5 V / 2 A / 25 W: + // bus: 12.5 V / 1.25 mV = 10000 = 0x2710 + // current: 2.0 A / 0.5 mA = 4000 = 0x0FA0 + // power: 25.0 W / 12.5 mW = 2000 = 0x07D0 + script_readings(bus, 0x2710, 0x07D0, 0x0FA0); + const size_t write16Before = [&] { + size_t n = 0; + for (const MockI2cBus::Call& call : bus.calls) { + n += call.type == MockI2cBus::Call::Type::Write16 ? 1 : 0; + } + return n; + }(); + TEST_ASSERT_TRUE(sensor.read()); + TEST_ASSERT_EQUAL_INT(0, sensor.getLastError()); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 12.5f, sensor.getBusVoltage()); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 2.0f, sensor.getCurrent()); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 25.0f, sensor.getPower()); + size_t write16After = 0; + for (const MockI2cBus::Call& call : bus.calls) { + write16After += call.type == MockI2cBus::Call::Type::Write16 ? 1 : 0; + } + TEST_ASSERT_EQUAL(write16Before + 2, write16After); // re-configured +} + +void test_ina226_is_available_live_probe(void) +{ + MockI2cBus bus; + script_identity(bus); + script_readings(bus, 0x2580, 0x0F00, 0x1F40); + Ina226Sensor sensor(bus, kAddr, kShuntMilliOhm); + TEST_ASSERT_TRUE(sensor.read()); + + // Available while the device answers its identity. + TEST_ASSERT_TRUE(sensor.isAvailable()); + + // Unplug: the availability probe detects the loss LIVE (never cached) + // and must not touch the reading's error code (still 0 from the + // successful read above). + bus.removeDevice(kAddr); + TEST_ASSERT_FALSE(sensor.isAvailable()); + TEST_ASSERT_EQUAL_INT(0, sensor.getLastError()); + + // Replug: the lazy re-init path recovers (isAvailable() delegates to + // initialize(), which owns its own error reporting). + script_identity(bus); + script_readings(bus, 0x2580, 0x0F00, 0x1F40); + TEST_ASSERT_TRUE(sensor.isAvailable()); + TEST_ASSERT_TRUE(sensor.read()); +} + +// --- LockedPowerSensor: pure delegation ------------------------------------ + +void test_locked_power_sensor_delegates(void) +{ + MockI2cBus bus; + script_identity(bus); + script_readings(bus, 0x2580, 0x0F00, 0x1F40); + Ina226Sensor raw(bus, kAddr, kShuntMilliOhm); + LockedPowerSensor sensor(raw); + + TEST_ASSERT_TRUE(sensor.initialize()); + TEST_ASSERT_TRUE(sensor.isAvailable()); + TEST_ASSERT_TRUE(sensor.read()); + TEST_ASSERT_EQUAL_INT(0, sensor.getLastError()); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 12.0f, sensor.getBusVoltage()); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 4.0f, sensor.getCurrent()); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 48.0f, sensor.getPower()); +} + } // namespace void run_ina226_tests(void) @@ -173,4 +536,17 @@ void run_ina226_tests(void) RUN_TEST(test_write16_queued_failure_leaves_map_untouched); RUN_TEST(test_write_outcome_queue_shared_across_widths_in_order); RUN_TEST(test_write16_wraps_register_index); + RUN_TEST(test_mock_word_registers_adjacent_no_collision); + RUN_TEST(test_ina226_calibration_operating_point); + RUN_TEST(test_ina226_initialize_writes_config_then_calibration); + RUN_TEST(test_ina226_getters_nan_before_first_read); + RUN_TEST(test_ina226_read_scales_lsb_units); + RUN_TEST(test_ina226_read_scales_pump_vector); + RUN_TEST(test_ina226_negative_current_sign_preserved); + RUN_TEST(test_ina226_identity_mismatch_is_error_1); + RUN_TEST(test_ina226_absent_device_is_error_1); + RUN_TEST(test_ina226_mid_init_write_failure_is_error_2); + RUN_TEST(test_ina226_mid_read_bus_error_lastgood_and_recovery); + RUN_TEST(test_ina226_is_available_live_probe); + RUN_TEST(test_locked_power_sensor_delegates); } diff --git a/firmware/test_apps/host/main/test_level_sensor.cpp b/firmware/test_apps/host/main/test_level_sensor.cpp index 0354485..7420818 100644 --- a/firmware/test_apps/host/main/test_level_sensor.cpp +++ b/firmware/test_apps/host/main/test_level_sensor.cpp @@ -16,8 +16,11 @@ * polarity equivalence (both board polarities produce identical logical * results for the same physical scenario), chatter collapsing to a single * transition, update-stream purity (time without update() changes - * nothing) and the LockedLevelSensor delegation check. Fail-direction - * truth tests (checklist line 97) arrive with T022. + * nothing) and the LockedLevelSensor delegation check. T021 (US4) adds + * the MockLevelSensor consumer-style tests (SC-006: all four PR-11 + * truth-table states across two instances, with coherent validity), and + * T022 the per-board fail-direction truths (docs/parity-checklist.md + * line 97 pinned as host-tested constants). * * Timing convention (DebouncedLevelSensor contract): a window of N ms is * complete on the first update() where at least N ms have elapsed since @@ -32,6 +35,7 @@ #include "interfaces/IDigitalInput.h" #include "sensors/DebouncedLevelSensor.h" #include "sensors/LockedLevelSensor.h" +#include "sensors/testing/MockLevelSensor.h" namespace { @@ -405,6 +409,119 @@ void test_locked_level_sensor_delegates(void) TEST_ASSERT_FALSE(sensor.isValid()); } +// --------------------------------------------------------------------------- +// Fail direction per board (T022, docs/parity-checklist.md line 97): +// a disconnected sensor input is pulled HIGH by the pull-up (internal on +// both boards, external 10 kΩ additionally on rev2 — research R4). The +// resulting LOGICAL state differs per board polarity, and BOTH directions +// fail safe for their pump topology — these tests pin the documented +// truths so a future polarity or pull change trips loudly. +// --------------------------------------------------------------------------- + +void test_fail_direction_rev1_disconnected_reads_water_present(void) +{ + // rev1 (two-pump node, active HIGH — non-inverting TXS0108E path): + // pulled-HIGH = "water present" at every mark ⇒ the reservoir looks + // full, so the FILL pump stays off. Fails toward "do not pump" + // (checklist line 97; legacy src/main.cpp:231-233 behavior preserved). + ScriptedInput input; + input.level = true; // disconnected: the pull-up pins the raw input HIGH + FakeTimeProvider time; + DebouncedLevelSensor sensor(input, time, /*activeLow=*/false, + kDebounceMs, /*settleMs=*/0); + + sensor.update(); + step(sensor, time, kDebounceMs); + TEST_ASSERT_TRUE(sensor.isValid()); + TEST_ASSERT_TRUE(sensor.isWaterPresent()); +} + +void test_fail_direction_rev2_disconnected_reads_water_absent(void) +{ + // rev2 (single-pump DRAWING node, active LOW — 2N7002 inverter): + // pulled-HIGH = "water absent" at every mark ⇒ the node believes the + // reservoir is empty and PR-11's controller will not run the plant + // pump dry. The opposite direction of rev1 — and exactly as safe, + // because the pump topology is opposite: rev1 fills a reservoir + // (phantom water = no overfill), rev2 draws from one (phantom + // emptiness = no dry run). Checklist line 97. + ScriptedInput input; + input.level = true; // disconnected: the pull-up pins the raw input HIGH + FakeTimeProvider time; + DebouncedLevelSensor sensor(input, time, /*activeLow=*/true, + kDebounceMs, kSettleMs); + + sensor.update(); + step(sensor, time, kSettleMs); // rev2 settle gate (FW-3) + step(sensor, time, kDebounceMs); // debounce warm-up + TEST_ASSERT_TRUE(sensor.isValid()); + TEST_ASSERT_FALSE(sensor.isWaterPresent()); +} + +// --------------------------------------------------------------------------- +// MockLevelSensor (T021): SC-006 consumer-style tests — two instances +// express all four PR-11 truth-table states with coherent validity. +// --------------------------------------------------------------------------- + +void test_mock_level_sensor_expresses_pr11_truth_table(void) +{ + // The four reservoir states PR-11's controller decides on (legacy + // truth table, docs/parity-checklist.md §3 / src/main.cpp:533-550), + // scripted exactly as a consumer will: gate on validity first, then + // read both logical states. + MockLevelSensor low; + MockLevelSensor high; + + struct Row { + bool lowWet; + bool highWet; + }; + // both wet (full) | low-only wet (sufficient) | both dry (start fill) + // | low dry + high wet (physically invalid — REPORTED as-is, this + // layer never masks it; interpreting it is PR-11's job). + const Row rows[] = { + {true, true}, {true, false}, {false, false}, {false, true}}; + + for (const Row& row : rows) { + low.scriptValidState(row.lowWet); + high.scriptValidState(row.highWet); + TEST_ASSERT_TRUE(low.isValid()); + TEST_ASSERT_TRUE(high.isValid()); + TEST_ASSERT_EQUAL(row.lowWet, low.isWaterPresent()); + TEST_ASSERT_EQUAL(row.highWet, high.isWaterPresent()); + } +} + +void test_mock_level_sensor_invalid_is_coherent(void) +{ + MockLevelSensor sensor; + + // Freshly constructed: not yet valid (the real sensor's settle/warm-up + // start) and the meaningless logical state reads false, never true. + TEST_ASSERT_FALSE(sensor.isValid()); + TEST_ASSERT_FALSE(sensor.isWaterPresent()); + + // A valid wet reading that goes invalid must NEVER leave a stale + // "water present" behind — the consistency the helpers enforce + // (PR-04 lesson: incoherent mock states hide consumer bugs). + sensor.scriptValidState(true); + TEST_ASSERT_TRUE(sensor.isWaterPresent()); + sensor.scriptInvalid(); + TEST_ASSERT_FALSE(sensor.isValid()); + TEST_ASSERT_FALSE(sensor.isWaterPresent()); + + // notifyPowerOn() mirrors the real settle re-arm: invalidates and is + // counted; update() only counts (scripted state is poll-stable). + sensor.scriptValidState(false); + sensor.notifyPowerOn(); + TEST_ASSERT_FALSE(sensor.isValid()); + TEST_ASSERT_EQUAL_INT(1, sensor.notifyPowerOnCalls); + sensor.update(); + sensor.update(); + TEST_ASSERT_EQUAL_INT(2, sensor.updateCalls); + TEST_ASSERT_FALSE(sensor.isValid()); // updates never revalidate a mock +} + } // namespace void run_level_sensor_tests(void) @@ -421,4 +538,8 @@ void run_level_sensor_tests(void) RUN_TEST(test_chatter_single_transition); RUN_TEST(test_raw_state_is_undebounced); RUN_TEST(test_locked_level_sensor_delegates); + RUN_TEST(test_fail_direction_rev1_disconnected_reads_water_present); + RUN_TEST(test_fail_direction_rev2_disconnected_reads_water_absent); + RUN_TEST(test_mock_level_sensor_expresses_pr11_truth_table); + RUN_TEST(test_mock_level_sensor_invalid_is_coherent); } diff --git a/specs/006-level-sensors-ina226/checklists/hil.md b/specs/006-level-sensors-ina226/checklists/hil.md new file mode 100644 index 0000000..e9572ca --- /dev/null +++ b/specs/006-level-sensors-ina226/checklists/hil.md @@ -0,0 +1,74 @@ +# HIL Checklist: Level Sensors, Capability Flag and INA226 (006) — rev1 bench rig + +**Purpose**: hardware-in-the-loop verification of PR-05 at Checkpoint 3 (Paul, bench rig) +**Rig**: ESP32 devkit + two XKC-Y26 level sensors on GPIO32 (low mark) / GPIO33 (high mark), internal pull-ups; pumps, BME280 and RS485 soil sensor as wired for the previous features +**Build**: rev1 target (`sdkconfig.board.rev1_devkit`), flash per `firmware/CLAUDE.md` +**Reference**: acceptance criteria `docs/prd/PR-05-level-sensors-ina226.md`; spec SC-003/SC-004/SC-005; quickstart.md §3 +**Note**: the XKC-Y26 can be hand-triggered (palm/water glass against the sensing face) — no filled reservoir needed. Remember the 300 ms debounce: state changes lag the physical event by ~0.3 s by design. + +## A. Level status wet/dry, both marks (US1, SC-003) + +- [ ] A1. Boot; within ~1 s of the first main-loop passes `level` answers + `OK low=… (raw=…) high=… (raw=…)` — no `not_yet_valid` lingers on + rev1 (settle 0 ms, only the 300 ms warm-up) +- [ ] A2. Both sensors dry → `level` shows `low=dry high=dry` +- [ ] A3. Trigger the LOW sensor (wet) → after ~0.3 s `level` shows + `low=water high=dry`; raw for the low sensor reads 1 (active HIGH) +- [ ] A4. Trigger BOTH sensors → `low=water high=water` +- [ ] A5. Trigger only the HIGH sensor → `low=dry high=water` — the + physically invalid combination is REPORTED as-is, never masked + (interpreting it is PR-11's job) +- [ ] A6. **Record the measured polarity in `docs/parity-checklist.md` + line 96** (the marked REV1 BENCH MEASUREMENT RECORD block): GPIO + level with water present, GPIO level without, and whether `level` + matched — measured values only (FR5 bench task, SC-003; this closes + the phase-1 polarity verification for rev1) + +## B. Debounce: chatter → single transition (US1, SC-005) + +- [ ] B1. From a stable dry state, hand-simulate slosh at the low mark + (rapidly waggle the trigger against the sensing face for ~1 s), then + hold it wet → repeated `level` polls show NO oscillation: `dry` + throughout the chatter, then exactly ONE transition to `water` +- [ ] B2. Same in the other direction (wet → chatter → dry): one clean + transition, no flapping +- [ ] B3. A short dip (<0.3 s) that returns to the stable state never + surfaces in `level` output (window restart behavior; host-tested + deterministically — this bench item is a spot check) + +## C. Fail direction: disconnect (US4, checklist line 97) + +- [ ] C1. Disconnect the LOW sensor signal wire mid-run → after ~0.3 s + `level` reads `low=water` (pull-up ⇒ HIGH ⇒ rev1 active HIGH = + "water present" — the fill-pump-stays-off direction); no crash, no + flapping +- [ ] C2. Reconnect → the true state returns within one debounce window +- [ ] C3. Confirm the direction is logged/annotated in the parity + checklist line 97 item (rev2's inverted direction is host-tested + only — no rev2 hardware yet) + +## D. Pump + console regression (US2, SC-004) + +- [ ] D1. Pumps OFF at boot (watch outputs during the A1 boot) — safety + invariant untouched by the capability-flag rework +- [ ] D2. `pump reservoir start 5` runs and self-stops; `pump reservoir + stop` and `pump reservoir status` behave as before (rev1 keeps both + pumps — no regression from the gating) +- [ ] D3. `pump status` lists BOTH pumps (plant + reservoir) +- [ ] D4. `env`, `soil`, `rs485test`, `config get`, `storage stats` still + respond normally (shared main-loop/console surface unchanged) +- [ ] D5. Main loop keeps its 10 Hz cadence with the two level update() + calls added: no watchdog resets, no missed pump self-stops during a + timed run while `level` is polled repeatedly + +## E. INA226 — host-covered, hardware at PR-14 + +- [ ] E1. Nothing to test on the rev1 rig (no INA226 device; + `BOARD_HAS_INA226=0` — the `power` command does not exist in this + build; verify `power` is an unknown command). Scaling, identity, + absence and recovery paths are host-tested deterministically in + `test_ina226.cpp`; on-hardware validation (incl. the written config + value, `TODO(PR-14)` in `Ina226Sensor.cpp`) is deferred to PR-14 by + the mini-PRD + +**Sign-off**: date + result per item as PR comment (pattern from PR #7). diff --git a/specs/006-level-sensors-ina226/tasks.md b/specs/006-level-sensors-ina226/tasks.md index a11b64c..1adaa42 100644 --- a/specs/006-level-sensors-ina226/tasks.md +++ b/specs/006-level-sensors-ina226/tasks.md @@ -39,21 +39,23 @@ app_main/diag_console/test-harness files. > the rev2 board profile, so the REV2 TARGET BUILD IS EXPECTED RED until T014 > guards app_main's unconditional references (task-order artifact of the > Mission 1/2 split). Host suite and rev1 target are unaffected. -- [ ] T014 [US2] Capability-gate reservoir pump wiring in `firmware/main/app_main.cpp`: instance creation, boot force-OFF and any references under `#if BOARD_HAS_RESERVOIR_PUMP`; verify the force-off-first invariant reads correctly on both boards (rev2 forces off exactly the plant pump) -- [ ] T015 [US2] Gate `pump reservoir` console registration in `firmware/main/diag_console.cpp` (compile-time absence on flag=0; `pump status` reports exactly the existing pumps) -- [ ] T016 [P] [US2] Host tests for capability gating in `firmware/test_apps/host/main/test_level_sensor.cpp` or existing pump suite: whatever pump-wiring logic is host-reachable stays green on both configs; at minimum assert the board-header contract compiles both ways (a compile-time test: rev1 path references the pin, shared code does not) — plus regression: full existing pump suite untouched (SC-004) + > RESOLVED (implementer, Mission 2): T014/T015 gated app_main + diag_console — + > rev2 green again is verified by T027's dual builds (main session). +- [x] T014 [US2] Capability-gate reservoir pump wiring in `firmware/main/app_main.cpp`: instance creation, boot force-OFF and any references under `#if BOARD_HAS_RESERVOIR_PUMP`; verify the force-off-first invariant reads correctly on both boards (rev2 forces off exactly the plant pump) +- [x] T015 [US2] Gate `pump reservoir` console registration in `firmware/main/diag_console.cpp` (compile-time absence on flag=0; `pump status` reports exactly the existing pumps) +- [x] T016 [P] [US2] Host tests for capability gating in `firmware/test_apps/host/main/test_level_sensor.cpp` or existing pump suite: whatever pump-wiring logic is host-reachable stays green on both configs; at minimum assert the board-header contract compiles both ways (a compile-time test: rev1 path references the pin, shared code does not) — plus regression: full existing pump suite untouched (SC-004) ## Phase 5: User Story 3 — Pump power telemetry on rev2 (P3) -- [ ] T017 [US3] Implement `Ina226Sensor` (pure) in `firmware/components/sensors/include/sensors/Ina226Sensor.h` + `src/Ina226Sensor.cpp`: identity check (0xFE==0x5449, 0xFF==0x2260), config + calibration writes (verify register field values against TI datasheet SBOS547 — flagged in research R7), CAL = 0.00512/(CurrentLSB×Rshunt) from constructor shunt param, scaling (busV ×1.25 mV; current signed ×0.5 mA; power ×25×CurrentLSB), NaN placeholders, errors 0/1/2, lazy re-init, uninitialize-on-bus-error. CMake (analyze finding I1, satisfies FR-011 literally): `Ina226Sensor.cpp` builds on linux always (host tests) and on target only when `CONFIG_BOARD_REV2` — the rev1 binary contains no INA226 code -- [ ] T018 [P] [US3] Host tests in `firmware/test_apps/host/main/test_ina226.cpp` (suite `run_ina226_tests()`): scaling vectors hand-computed from datasheet formulas incl. the 5 mΩ/0.5 mA operating point (CAL=2048) and a negative-current case (sign preserved); identity mismatch → error 1; absent device → error 1; mid-read bus error → error 2 + last-good + recovery re-probe; config/cal write bytes asserted big-endian in order -- [ ] T019 [P] [US3] Implement `LockedPowerSensor` in `firmware/components/sensors/include/sensors/LockedPowerSensor.h` -- [ ] T020 [US3] Kconfig `WS_INA226_SHUNT_MILLIOHM` (int, default 5, range 1–1000) in `firmware/main/Kconfig.projbuild`; `power` console command (`diag_console_register_power(IPowerSensor&)`) in `firmware/main/diag_console.h/.cpp` — error output distinguishes 1 vs 2; wire `Ina226Sensor` on the SHARED EspI2cBus instance in `firmware/main/app_main.cpp` under `#if BOARD_HAS_INA226` +- [x] T017 [US3] Implement `Ina226Sensor` (pure) in `firmware/components/sensors/include/sensors/Ina226Sensor.h` + `src/Ina226Sensor.cpp`: identity check (0xFE==0x5449, 0xFF==0x2260), config + calibration writes (verify register field values against TI datasheet SBOS547 — flagged in research R7), CAL = 0.00512/(CurrentLSB×Rshunt) from constructor shunt param, scaling (busV ×1.25 mV; current signed ×0.5 mA; power ×25×CurrentLSB), NaN placeholders, errors 0/1/2, lazy re-init, uninitialize-on-bus-error. CMake (analyze finding I1, satisfies FR-011 literally): `Ina226Sensor.cpp` builds on linux always (host tests) and on target only when `CONFIG_BOARD_REV2` — the rev1 binary contains no INA226 code +- [x] T018 [P] [US3] Host tests in `firmware/test_apps/host/main/test_ina226.cpp` (suite `run_ina226_tests()`): scaling vectors hand-computed from datasheet formulas incl. the 5 mΩ/0.5 mA operating point (CAL=2048) and a negative-current case (sign preserved); identity mismatch → error 1; absent device → error 1; mid-read bus error → error 2 + last-good + recovery re-probe; config/cal write bytes asserted big-endian in order +- [x] T019 [P] [US3] Implement `LockedPowerSensor` in `firmware/components/sensors/include/sensors/LockedPowerSensor.h` +- [x] T020 [US3] Kconfig `WS_INA226_SHUNT_MILLIOHM` (int, default 5, range 1–1000) in `firmware/main/Kconfig.projbuild`; `power` console command (`diag_console_register_power(IPowerSensor&)`) in `firmware/main/diag_console.h/.cpp` — error output distinguishes 1 vs 2; wire `Ina226Sensor` on the SHARED EspI2cBus instance in `firmware/main/app_main.cpp` under `#if BOARD_HAS_INA226` ## Phase 6: User Story 4 — Behavior testable without hardware (P4) -- [ ] T021 [P] [US4] Create `MockLevelSensor` in `firmware/components/sensors/include/sensors/testing/MockLevelSensor.h` with consistency helpers (`scriptValidState(present)`, `scriptInvalid()`); host tests proving all four PR-11 truth-table combinations expressible across two instances (incl. low-dry+high-wet) with coherent validity — the SC-006 consumer-style test -- [ ] T022 [P] [US4] Fail-direction truth tests in `firmware/test_apps/host/main/test_level_sensor.cpp`: disconnected-input simulation (raw pulled HIGH) → rev1 config reads "water present", rev2 config reads "water absent" — checklist line 97 pinned per board with a comment citing the pump-topology safety rationale +- [x] T021 [P] [US4] Create `MockLevelSensor` in `firmware/components/sensors/include/sensors/testing/MockLevelSensor.h` with consistency helpers (`scriptValidState(present)`, `scriptInvalid()`); host tests proving all four PR-11 truth-table combinations expressible across two instances (incl. low-dry+high-wet) with coherent validity — the SC-006 consumer-style test +- [x] T022 [P] [US4] Fail-direction truth tests in `firmware/test_apps/host/main/test_level_sensor.cpp`: disconnected-input simulation (raw pulled HIGH) → rev1 config reads "water present", rev2 config reads "water absent" — checklist line 97 pinned per board with a comment citing the pump-topology safety rationale - [x] T023 [US4] Register both new suites in `firmware/test_apps/host/main/CMakeLists.txt` + `test_main.cpp` (`run_level_sensor_tests`, `run_ina226_tests`) > NOTE (implementer, Mission 1): pulled forward and completed together with > T006/T009 — both suite files must exist and be registered for the host app @@ -63,10 +65,10 @@ app_main/diag_console/test-harness files. ## Phase 7: Polish & Cross-Cutting -- [ ] T024 [P] Update `firmware/CLAUDE.md`: directory tree, console commands (`level`, `power`, gated `pump reservoir`), new sections (level sensors: polarity/debounce/settle + capability flag rationale; INA226: shared-bus rule, Kconfig shunt, PR-14 deferral), host-test description -- [ ] T025 [P] `docs/parity-checklist.md`: §6 divergence entries (debounce, not-yet-valid gating, INA226 identity check, reservoir-pump capability flag) per contracts/interfaces.md list; prepare the line-96 recording slot for the rev1 bench measurement (do NOT fill it — Paul's HIL records it) -- [ ] T026 [P] Write HIL checklist `specs/006-level-sensors-ina226/checklists/hil.md` (rev1 rig): A level status wet/dry both marks + record measured polarity in parity checklist line 96, B chatter → single transition, C fail direction (disconnect), D pump regression (`pump reservoir` + boot force-off + `env`/`soil` intact), E INA226 note (host-covered, hardware at PR-14) -- [ ] T027 Full verification per quickstart.md: host suite exit 0, rev1+rev2 green from clean checkout, `dependencies.lock` unchanged; capture outputs for the CP3 dossier +- [x] T024 [P] Update `firmware/CLAUDE.md`: directory tree, console commands (`level`, `power`, gated `pump reservoir`), new sections (level sensors: polarity/debounce/settle + capability flag rationale; INA226: shared-bus rule, Kconfig shunt, PR-14 deferral), host-test description +- [x] T025 [P] `docs/parity-checklist.md`: §6 divergence entries (debounce, not-yet-valid gating, INA226 identity check, reservoir-pump capability flag) per contracts/interfaces.md list; prepare the line-96 recording slot for the rev1 bench measurement (do NOT fill it — Paul's HIL records it) +- [x] T026 [P] Write HIL checklist `specs/006-level-sensors-ina226/checklists/hil.md` (rev1 rig): A level status wet/dry both marks + record measured polarity in parity checklist line 96, B chatter → single transition, C fail direction (disconnect), D pump regression (`pump reservoir` + boot force-off + `env`/`soil` intact), E INA226 note (host-covered, hardware at PR-14) +- [x] T027 Full verification per quickstart.md: host suite exit 0, rev1+rev2 green from clean checkout, `dependencies.lock` unchanged; capture outputs for the CP3 dossier ## Dependencies & Execution Order From 59f72cf512578d07f364b436f692cb300cc71f57 Mon Sep 17 00:00:00 2001 From: Cryptotomte Date: Fri, 3 Jul 2026 10:28:28 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(sensors):=20CP3=20review=20=E2=80=94=20?= =?UTF-8?q?fault=20latch,=20invalid-state=20truth,=20test=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings F1-F15: level GPIO init failures now fault-latch the debounce state machine (isValid stays false until a deliberate notifyPowerOn re-arm - a floating unconfigured pin can no longer become a valid reading) with per-sensor init evaluation and logging (the || short-circuit skipped the high-mark init entirely); isWaterPresent() returns false whenever invalid (was polarity-mapped construction default - phantom water-present on rev2 during settle; driver, interface doc and mock now agree); MockI2cBus aborts loudly on mismatched-shape reads of setRegister16-scripted words; stable textual anchors replace the self-broken parity-checklist line-97 citations; 8 new host tests (fault latch, invalid-reads-false, INA226 snapshot atomicity, calibration-write failure, identity-read failure, settle dead-time, intra-settle chatter, rawState polarity independence) - suite 164/0, rev1+rev2 green; comment/contract drift corrected incl. PRD macro rename. Co-Authored-By: Claude Fable 5 --- docs/prd/PR-02-pump-gpio-board.md | 2 +- docs/prd/PR-05-level-sensors-ina226.md | 2 +- docs/prd/PR-14-rev2-bringup.md | 2 +- firmware/CLAUDE.md | 3 +- .../components/board/include/board/board.h | 2 +- .../interfaces/include/interfaces/II2cBus.h | 4 +- .../include/interfaces/ILevelSensor.h | 16 +- .../include/interfaces/IPowerSensor.h | 4 +- .../include/sensors/DebouncedLevelSensor.h | 31 ++- .../sensors/include/sensors/GpioLevelSensor.h | 10 +- .../sensors/include/sensors/Ina226Sensor.h | 4 +- .../include/sensors/testing/MockI2cBus.h | 51 ++++- .../include/sensors/testing/MockLevelSensor.h | 10 +- .../sensors/src/DebouncedLevelSensor.cpp | 40 +++- .../sensors/src/GpioLevelSensor.cpp | 8 +- firmware/main/app_main.cpp | 38 +++- firmware/main/diag_console.cpp | 16 +- firmware/test_apps/host/main/test_ina226.cpp | 108 +++++++++- .../test_apps/host/main/test_level_sensor.cpp | 187 +++++++++++++++++- .../checklists/hil.md | 6 +- .../contracts/interfaces.md | 35 +++- specs/006-level-sensors-ina226/research.md | 5 +- 22 files changed, 505 insertions(+), 79 deletions(-) diff --git a/docs/prd/PR-02-pump-gpio-board.md b/docs/prd/PR-02-pump-gpio-board.md index 067b4a9..d96e660 100644 --- a/docs/prd/PR-02-pump-gpio-board.md +++ b/docs/prd/PR-02-pump-gpio-board.md @@ -18,7 +18,7 @@ board feature flags. level 33, status LED 2, buttons 5 / 18. - **REV2** provisional pin table + feature flags: `BOARD_HAS_RS485_DE` (rev1 only, rev2 THVD1426 is auto-direction), `BOARD_HAS_INA226` (rev2 only), - `BOARD_LEVEL_SENSOR_ACTIVE_LOW` (rev2 only, 2N7002 inverter). Final rev2 pin + `BOARD_LEVEL_ACTIVE_LOW` (rev2 only, 2N7002 inverter). Final rev2 pin numbers land at **SYNC 1**. - `IActuator` / `IWaterPump` interfaces ported to pure C++ (no Arduino types, `std::string` instead of `String`), placed so host tests can include them without diff --git a/docs/prd/PR-05-level-sensors-ina226.md b/docs/prd/PR-05-level-sensors-ina226.md index 0f7bb22..c10666b 100644 --- a/docs/prd/PR-05-level-sensors-ina226.md +++ b/docs/prd/PR-05-level-sensors-ina226.md @@ -15,7 +15,7 @@ watering-pump channel on rev2. ### Level sensors (XKC-Y26, GPIO 32 low / GPIO 33 high) - `LevelSensor` abstraction returning logical *water present / not present*; raw GPIO - polarity selected by `BOARD_LEVEL_SENSOR_ACTIVE_LOW` from the board component. + polarity selected by `BOARD_LEVEL_ACTIVE_LOW` from the board component. - Polarity facts (master PRD FR5, from the 2026-04-12 fix branch, verified against the KiCad schematic): XKC-Y26 OUT is **active HIGH** (water = HIGH). Rev1 routes OUT directly via TXS0108E (non-inverting) ⇒ GPIO active HIGH. Rev2 routes via a 2N7002 diff --git a/docs/prd/PR-14-rev2-bringup.md b/docs/prd/PR-14-rev2-bringup.md index a503532..e59cab4 100644 --- a/docs/prd/PR-14-rev2-bringup.md +++ b/docs/prd/PR-14-rev2-bringup.md @@ -27,7 +27,7 @@ the greenhouse and retire the Arduino unit after a two-week unattended soak. Greenhouse reservoir is refilled manually until the central reservoir unit exists (multi-zone, future PRD). - **Inverted level sensors:** XKC-Y26 through 2N7002 inverter ⇒ GPIO active LOW; - wet/dry bench measurement confirms `BOARD_LEVEL_SENSOR_ACTIVE_LOW` mapping + wet/dry bench measurement confirms `BOARD_LEVEL_ACTIVE_LOW` mapping (counterpart of the PR-05 rev1 verification). - **JTAG smoke test:** attach debugger on header J6, halt/resume, read a variable — proves the debug path for future use. diff --git a/firmware/CLAUDE.md b/firmware/CLAUDE.md index 28a4fbb..e256d92 100644 --- a/firmware/CLAUDE.md +++ b/firmware/CLAUDE.md @@ -308,7 +308,8 @@ not-yet-valid for `BOARD_LEVEL_SETTLE_MS` after a power-on event (rev1 0, rev2 500 ms); `notifyPowerOn()` re-arms the gate — app_main calls it once at boot, real rail control (`SENS_PWR_EN`) arrives with PR-14. Consumers MUST gate on `isValid()`: not-yet-valid is a distinct state, never wet or -dry. Fail direction (pinned by host tests, parity checklist line 97): a +dry. Fail direction (pinned by host tests, parity checklist §3, "Pull-up + +active-HIGH consequence" item): a disconnected input reads pulled-HIGH ⇒ rev1 "water present" (fill pump stays off), rev2 "water absent" (drawing node does not pump) — both fail safe for their pump topology. `update()` is polled from the 10 Hz main diff --git a/firmware/components/board/include/board/board.h b/firmware/components/board/include/board/board.h index 5706489..cdd1f60 100644 --- a/firmware/components/board/include/board/board.h +++ b/firmware/components/board/include/board/board.h @@ -109,7 +109,7 @@ * defined when BOARD_HAS_RESERVOIR_PUMP is 0: any reference that is not * guarded by #if BOARD_HAS_RESERVOIR_PUMP becomes a compile error instead * of driving a phantom GPIO (same enforcement pattern as - * BOARD_PIN_RS485_DE below). */ + * BOARD_PIN_RS485_DE above). */ #define BOARD_PIN_MAIN_PUMP 26 // TODO(SYNC1): final rev2 pin map frozen at hardware sync 1 #define BOARD_HAS_RESERVOIR_PUMP 0 diff --git a/firmware/components/interfaces/include/interfaces/II2cBus.h b/firmware/components/interfaces/include/interfaces/II2cBus.h index 528803d..933b9fc 100644 --- a/firmware/components/interfaces/include/interfaces/II2cBus.h +++ b/firmware/components/interfaces/include/interfaces/II2cBus.h @@ -28,8 +28,8 @@ #include /** - * @brief I2C master: probe + 8-bit register reads/writes, one transaction - * per call. + * @brief I2C master: probe + 8/16-bit register reads/writes, one + * transaction per call. * * All addresses are 7-bit. Every method returns false on NACK, bus error * or timeout; there are NO retries at this layer — recovery policy belongs diff --git a/firmware/components/interfaces/include/interfaces/ILevelSensor.h b/firmware/components/interfaces/include/interfaces/ILevelSensor.h index 5eb09a4..489e718 100644 --- a/firmware/components/interfaces/include/interfaces/ILevelSensor.h +++ b/firmware/components/interfaces/include/interfaces/ILevelSensor.h @@ -36,7 +36,8 @@ * @brief Polled, debounced, polarity-absorbed water-level state with * explicit validity. * - * Fail direction (pinned by host tests, docs/parity-checklist.md line 97): + * Fail direction (pinned by host tests, docs/parity-checklist.md §3, + * "Pull-up + active-HIGH consequence" item): * a disconnected input is pulled HIGH by the board's pull-up, so rev1 * (active HIGH) reads "water present" — the fill pump stays off — while * rev2 (active LOW, 2N7002 inverter) reads "water absent" — the drawing @@ -62,9 +63,12 @@ class ILevelSensor { * * False during settle gating (after construction or notifyPowerOn(), * FW-3) and during debounce warm-up (until the raw input has held one - * value for a full stability window). Consumers MUST gate on this — - * "not yet valid" is a distinct state, never to be conflated with - * wet or dry (SC-005/FR-012). + * value for a full stability window). Implementations may additionally + * pin this false for a latched fault (DebouncedLevelSensor:: + * markFaulted() — a wiring-site concept, deliberately not part of this + * interface). Consumers MUST gate on this — "not yet valid" is a + * distinct state, never to be conflated with wet or dry + * (FR-001/FR-003/FR-012). */ virtual bool isValid() = 0; @@ -76,8 +80,8 @@ class ILevelSensor { * raw-polarity value except through rawState(). Debounced: the value * changes only after the raw input has been stable in the new state * for the board's debounce window; any flip restarts the window. - * Meaningful ONLY while isValid() returns true (returns false before - * the first stable window, by definition without meaning). + * Meaningful ONLY while isValid() returns true (returns false whenever + * invalid — never a stale or phantom value). */ virtual bool isWaterPresent() = 0; diff --git a/firmware/components/interfaces/include/interfaces/IPowerSensor.h b/firmware/components/interfaces/include/interfaces/IPowerSensor.h index 9e73bc1..af83b83 100644 --- a/firmware/components/interfaces/include/interfaces/IPowerSensor.h +++ b/firmware/components/interfaces/include/interfaces/IPowerSensor.h @@ -61,8 +61,8 @@ class IPowerSensor { * together. * * Atomic: either all three getters are refreshed, or the call fails - * (error 1 when the lazy (re-)initialization finds no sensor, error 2 - * on a bus error during the data reads) and the last-good values + * (error 1 or 2 from a failed lazy (re-)initialization, error 2 on a + * bus error during the data reads) and the last-good values * remain untouched. A bus error marks the driver uninitialized, so * the next call re-probes the identity (recovery). No retries — * recovery comes from the caller's poll cadence. diff --git a/firmware/components/sensors/include/sensors/DebouncedLevelSensor.h b/firmware/components/sensors/include/sensors/DebouncedLevelSensor.h index a25f583..8a40624 100644 --- a/firmware/components/sensors/include/sensors/DebouncedLevelSensor.h +++ b/firmware/components/sensors/include/sensors/DebouncedLevelSensor.h @@ -44,12 +44,20 @@ * TRACKING: a raw flip opens a stability window; the reported state * changes only once the new value has held for debounceMs; * every flip restarts the window. + * markFaulted() ──▶ FAULTED from any state (latched: update() does not + * progress, isValid() stays false); only + * notifyPowerOn() — a deliberate re-arm — leaves + * it, back to SETTLING. * * All time is measured from the update() stream: the settle window starts * at the FIRST update() after construction/notifyPowerOn() (never at the * event itself — "no update ⇒ no state change", ILevelSensor contract; the * slight lateness is in the safe direction). A window of N ms is complete * on the first update() where at least N ms have elapsed since it opened. + * Sampled-system caveat: the raw value only has to be identical at the + * samples the owner takes — with a starved poll cadence a window can + * complete from just two samples spanning it, not a continuously observed + * hold. */ class DebouncedLevelSensor : public ILevelSensor { public: @@ -65,10 +73,13 @@ class DebouncedLevelSensor : public ILevelSensor { * means water present (rev2's 2N7002 inverter); pass * BOARD_LEVEL_ACTIVE_LOW from board.h. * @param debounceMs Stability window before a reported state change - * (BOARD_LEVEL_DEBOUNCE_MS). + * (BOARD_LEVEL_DEBOUNCE_MS). Negative values are + * clamped to 0 (document-and-clamp — never + * already-in-the-past window math). * @param settleMs Invalidity window after a power-on event * (BOARD_LEVEL_SETTLE_MS; 0 = no settle gating - * beyond the debounce warm-up). + * beyond the debounce warm-up). Negative values are + * clamped to 0. */ DebouncedLevelSensor(IDigitalInput& input, ITimeProvider& time, bool activeLow, int64_t debounceMs, @@ -86,8 +97,22 @@ class DebouncedLevelSensor : public ILevelSensor { bool rawState() override; void notifyPowerOn() override; + /** + * @brief Latch the sensor in a FAULTED state: isValid() is pinned + * false and update() no longer progresses the state machine. + * + * Deliberately NOT part of ILevelSensor (the interface surface stays + * unchanged): faulting is a wiring-site decision — app_main calls this + * on the concrete object when the raw input's GPIO init failed, so an + * unconfigured, floating pin can never debounce its way to a "valid" + * reading. Recovery is a deliberate re-arm only: notifyPowerOn() + * returns the sensor to Settling (PR-14 rail control, or an operator + * power cycle — which reboots and re-runs the GPIO init anyway). + */ + void markFaulted(); + private: - enum class State { Settling, Warmup, Tracking }; + enum class State { Settling, Warmup, Tracking, Faulted }; /// Sentinel: the settle window has not started yet (armed, waiting for /// the first update()). diff --git a/firmware/components/sensors/include/sensors/GpioLevelSensor.h b/firmware/components/sensors/include/sensors/GpioLevelSensor.h index 084cd53..bb60228 100644 --- a/firmware/components/sensors/include/sensors/GpioLevelSensor.h +++ b/firmware/components/sensors/include/sensors/GpioLevelSensor.h @@ -26,8 +26,8 @@ * on rev1 (legacy src/main.cpp:231-233 uses INPUT_PULLUP, * docs/parity-checklist.md line 95), redundant-but-harmless on rev2 on top * of the external 10 kΩ. The pull-up also pins the documented fail - * direction: a disconnected input reads HIGH (docs/parity-checklist.md - * line 97). + * direction: a disconnected input reads HIGH (docs/parity-checklist.md §3, + * "Pull-up + active-HIGH consequence" item). */ class GpioLevelSensor : public IDigitalInput { public: @@ -48,8 +48,10 @@ class GpioLevelSensor : public IDigitalInput { * @brief Configure the pin as input with the internal pull-up enabled. * * @return true on success; false is logged by the implementation — - * read() then still returns the (unconfigured) pin level and - * the caller decides how loudly to complain. + * read() then still returns the (unconfigured) pin level, and + * the wiring site latches the consuming DebouncedLevelSensor + * Faulted (markFaulted) so that garbage never becomes a valid + * reading. */ bool initialize(); diff --git a/firmware/components/sensors/include/sensors/Ina226Sensor.h b/firmware/components/sensors/include/sensors/Ina226Sensor.h index 5d462c0..6bf4c1c 100644 --- a/firmware/components/sensors/include/sensors/Ina226Sensor.h +++ b/firmware/components/sensors/include/sensors/Ina226Sensor.h @@ -129,8 +129,8 @@ class Ina226Sensor : public IPowerSensor { static constexpr uint8_t kRegDieId = 0xFF; /// Configuration register value: AVG ×16, VBUSCT = VSHCT = 1.1 ms, - /// MODE = continuous shunt + bus. Bit-field derivation at the write - /// site in Ina226Sensor.cpp; asserted byte-exact by host tests. + /// MODE = continuous shunt + bus. Bit-field derivation at the top of + /// Ina226Sensor.cpp; asserted byte-exact by host tests. static constexpr uint16_t kConfigValue = 0x4527; /// Probe the address and verify manufacturer + die ID. diff --git a/firmware/components/sensors/include/sensors/testing/MockI2cBus.h b/firmware/components/sensors/include/sensors/testing/MockI2cBus.h index e02faa3..a155e4d 100644 --- a/firmware/components/sensors/include/sensors/testing/MockI2cBus.h +++ b/firmware/components/sensors/include/sensors/testing/MockI2cBus.h @@ -24,8 +24,15 @@ * spills into the numerically next register, so adjacent word registers * like 0x02/0x03/0x04 or 0xFE/0xFF do not collide). An exact 2-byte read * is served from the word overlay when the register is scripted there; - * everything else falls through to the byte map. Never compiled into - * target builds (only included from test code). No IDF includes. + * everything else falls through to the byte map — EXCEPT when the read + * range touches a register whose only truth is the word overlay + * (setRegister16() scripted, byte map incoherent): that is a mis-shaped + * test script, and instead of silently serving byte-map zeros the mock + * fails loudly (abort(), host-only code — see readRegisters()). + * writeRegister16() keeps the byte map coherent (big-endian mirror), so + * byte-level reads of driver-written registers remain valid. Never + * compiled into target builds (only included from test code). No IDF + * includes. */ #ifndef WATERINGSYSTEM_SENSORS_TESTING_MOCKI2CBUS_H @@ -34,7 +41,10 @@ #include #include #include +#include +#include #include +#include #include #include "interfaces/II2cBus.h" @@ -80,6 +90,7 @@ class MockI2cBus : public II2cBus { { devices_.erase(address7); wordRegisters_.erase(address7); + byteIncoherentWords_.erase(address7); } /// Script one register byte on a device (added implicitly if absent). @@ -111,6 +122,11 @@ class MockI2cBus : public II2cBus { { devices_[address7]; // implicit add, setRegister() convention wordRegisters_[address7][reg] = value; + // The byte map is deliberately NOT mirrored (a pointer-addressed + // register has no byte-map representation): mark the register + // byte-incoherent so a mis-shaped read fails loudly instead of + // serving byte-map zeros (see readRegisters()). + byteIncoherentWords_[address7].insert(reg); } /** @@ -160,6 +176,30 @@ class MockI2cBus : public II2cBus { } } } + // Loud-failure guard (host-only code): a read that is NOT an exact + // 2-byte overlay hit but whose range touches a register that ONLY + // exists in the word overlay (setRegister16() scripted, never + // writeRegister16()-mirrored into the byte map) would silently + // serve byte-map zeros — always a mis-shaped test script, never a + // modeled bus behavior. Abort with a message instead; this path + // can therefore not appear as a passing test, only as a build-time + // documented contract (see the file comment). + const auto incoherent = byteIncoherentWords_.find(address7); + if (incoherent != byteIncoherentWords_.end()) { + for (size_t i = 0; i < len; ++i) { + const uint8_t r = static_cast(startReg + i); + if (incoherent->second.count(r) != 0) { + std::fprintf( + stderr, + "MockI2cBus: read [reg 0x%02x, len %zu] at addr " + "0x%02x overlaps word-scripted register 0x%02x " + "without an exact 2-byte hit — mis-shaped test " + "script (use readRegister16/exact 2-byte reads)\n", + startReg, len, address7, r); + std::abort(); + } + } + } for (size_t i = 0; i < len; ++i) { buf[i] = device->second[static_cast(startReg + i)]; } @@ -200,7 +240,10 @@ class MockI2cBus : public II2cBus { static_cast(value & 0xFF); // Also into the WORD overlay, so a write-then-read16 round trip is // coherent for pointer-addressed devices (INA226 config readback). + // The byte map is now coherent for this register — clear any + // setRegister16() incoherence mark (loud-failure guard above). wordRegisters_[address7][reg] = value; + byteIncoherentWords_[address7].erase(reg); call.succeeded = true; calls.push_back(call); return true; @@ -222,6 +265,10 @@ class MockI2cBus : public II2cBus { /// Pointer-addressed 16-bit registers (setRegister16(), INA226 model); /// consulted only for exact 2-byte reads. std::map> wordRegisters_; + /// Word registers whose byte-map view is NOT valid (setRegister16() + /// scripted and not since writeRegister16()-mirrored): any non-exact + /// read touching one aborts loudly (see readRegisters()). + std::map> byteIncoherentWords_; std::vector readOutcomes_; std::vector writeOutcomes_; }; diff --git a/firmware/components/sensors/include/sensors/testing/MockLevelSensor.h b/firmware/components/sensors/include/sensors/testing/MockLevelSensor.h index 3ae9a00..241824a 100644 --- a/firmware/components/sensors/include/sensors/testing/MockLevelSensor.h +++ b/firmware/components/sensors/include/sensors/testing/MockLevelSensor.h @@ -16,8 +16,10 @@ * Consistency helpers from the start (PR-04 lesson, MockEnvironmentalSensor * pattern): scriptValidState()/scriptInvalid() keep validity and the * logical state coherent — an invalid sensor never serves a stale - * "water present" (the interface contract: isWaterPresent() is meaningful - * ONLY while isValid(), and returns false when it carries no meaning). + * "water present". This matches the interface contract exactly: + * isWaterPresent() returns false whenever invalid (never a stale or + * phantom value), which the real DebouncedLevelSensor also guarantees + * structurally. * rawState() is a separate plain scripted field: it is diagnostics-only * and polarity-dependent, which a board-agnostic mock cannot derive from * the logical state — no coherence is defined between the two (matching @@ -60,8 +62,8 @@ class MockLevelSensor : public ILevelSensor { } /// Script a NOT-YET-VALID sensor (settling/warming up/faulted): the - /// logical state loses meaning and is served as false per the - /// interface contract — never a stale previous value. + /// logical state loses meaning and is served as false — the interface + /// contract shared with the real sensor, never a stale previous value. void scriptInvalid() { valid_ = false; diff --git a/firmware/components/sensors/src/DebouncedLevelSensor.cpp b/firmware/components/sensors/src/DebouncedLevelSensor.cpp index dbc3c3b..dca3185 100644 --- a/firmware/components/sensors/src/DebouncedLevelSensor.cpp +++ b/firmware/components/sensors/src/DebouncedLevelSensor.cpp @@ -18,9 +18,11 @@ DebouncedLevelSensor::DebouncedLevelSensor(IDigitalInput& input, : input_(input), time_(time), activeLow_(activeLow), - debounceMs_(debounceMs), - settleMs_(settleMs) + debounceMs_(debounceMs < 0 ? 0 : debounceMs), + settleMs_(settleMs < 0 ? 0 : settleMs) { + // Negative windows are clamped to 0 (document-and-clamp, header + // contract): a window that "completed in the past" must never exist. // Starts in SETTLING with the settle window armed but not started — // the first update() opens it (no time read in the constructor; all // state motion belongs to the update() stream). @@ -73,6 +75,12 @@ void DebouncedLevelSensor::update() stableRaw_ = candidateRaw_; } break; + + case State::Faulted: + // Latched (markFaulted): no progress on any update — only + // notifyPowerOn(), a deliberate re-arm, leaves this state. raw_ + // above still tracks the pin for rawState() diagnostics. + break; } } @@ -84,11 +92,12 @@ bool DebouncedLevelSensor::isValid() bool DebouncedLevelSensor::isWaterPresent() { // Polarity absorbed here (FW-5): logical = stable raw XOR active-low. - // Meaningful only while isValid() (ILevelSensor contract); before the - // first stable window stableRaw_ is the constructed false, and after a - // notifyPowerOn() it holds the last stable value — consumers gate on - // isValid(), never on this value alone. - return activeLow_ ? !stableRaw_ : stableRaw_; + // Structurally false whenever invalid (ILevelSensor contract: never a + // stale or phantom value) — outside TRACKING there is no stable value + // to map, so nothing meaningless can leak even to a consumer that + // forgot to gate on isValid(). + return state_ == State::Tracking && + (activeLow_ ? !stableRaw_ : stableRaw_); } bool DebouncedLevelSensor::rawState() @@ -98,10 +107,21 @@ bool DebouncedLevelSensor::rawState() void DebouncedLevelSensor::notifyPowerOn() { - // Re-arm settle gating (FW-3) from ANY state: readings are invalid - // again until the settle window AND a fresh debounce warm-up complete. - // The settle window opens at the next update() (kNotStarted), keeping + // Re-arm settle gating (FW-3) from ANY state — including FAULTED, + // where this deliberate re-arm is the documented recovery path (PR-14 + // rail control / an operator power cycle): readings are invalid again + // until the settle window AND a fresh debounce warm-up complete. The + // settle window opens at the next update() (kNotStarted), keeping // validity a pure function of the update stream. state_ = State::Settling; settleStartMs_ = kNotStarted; } + +void DebouncedLevelSensor::markFaulted() +{ + // Latched fault (header contract): called at the wiring site when the + // raw input's GPIO init failed — a floating, unconfigured pin must + // never debounce its way to isValid(). Cleared only by + // notifyPowerOn(). + state_ = State::Faulted; +} diff --git a/firmware/components/sensors/src/GpioLevelSensor.cpp b/firmware/components/sensors/src/GpioLevelSensor.cpp index b8b2a1b..68cd084 100644 --- a/firmware/components/sensors/src/GpioLevelSensor.cpp +++ b/firmware/components/sensors/src/GpioLevelSensor.cpp @@ -6,9 +6,11 @@ * * Error handling is explicit (not ESP_ERROR_CHECK): a misconfigured level * input must be visible in the logs regardless of the configured assertion - * level, and must never abort — level sensing is not boot-critical (the - * DebouncedLevelSensor above reports not-yet-valid/garbage-gated states, - * and PR-11's fail-safe treats invalid as "do not act"). + * level, and must never abort — level sensing is not boot-critical. On an + * initialize() failure the wiring site (app_main) latches the + * DebouncedLevelSensor above into its Faulted state (markFaulted), so the + * floating, unconfigured pin is never debounced into a valid reading and + * PR-11's fail-safe treats invalid as "do not act". */ #include "sensors/GpioLevelSensor.h" diff --git a/firmware/main/app_main.cpp b/firmware/main/app_main.cpp index dfbcb0a..d2e4726 100644 --- a/firmware/main/app_main.cpp +++ b/firmware/main/app_main.cpp @@ -113,7 +113,8 @@ static void pumps_force_off(void) extern "C" void app_main(void) { - // Fail-safe first: both pumps off before anything else happens. + // Fail-safe first: every pump that exists on this board off before + // anything else happens. pumps_force_off(); const esp_app_desc_t *app_desc = esp_app_get_description(); @@ -280,8 +281,10 @@ extern "C" void app_main(void) // Reservoir level sensors (feature 006). Not safety-critical at boot: // a failed GPIO init is logged and the system keeps running — the - // sensors report not-yet-valid/garbage-gated states and PR-11's - // fail-safe treats invalid as "do not act". Function-local statics + // affected sensor is latched Faulted (markFaulted below), so it + // reports not-yet-valid forever instead of debouncing a floating pin + // into a "valid" reading, and PR-11's fail-safe treats invalid as + // "do not act". Function-local statics // after pumps_force_off() (boot fail-safe rule). Split per research // R1: GpioLevelSensor is the raw pin read (input + pull-up, no logic); // DebouncedLevelSensor holds ALL policy — polarity (FW-5), debounce @@ -300,10 +303,10 @@ extern "C" void app_main(void) static LockedLevelSensor level_low(level_low_raw); static LockedLevelSensor level_high(level_high_raw); - if (!level_low_input.initialize() || !level_high_input.initialize()) { - ESP_LOGE(TAG, "level sensor GPIO init failed — level readings " - "unreliable"); - } + // Both inits run unconditionally (no short-circuit): a low-input + // failure must never skip the high-input init. + const bool level_low_ok = level_low_input.initialize(); + const bool level_high_ok = level_high_input.initialize(); // FW-3: the sensor rail is on from power-up (rail *control* arrives in // PR-14) — arm the settle gate once at boot. On rev1 the settle window @@ -311,6 +314,27 @@ extern "C" void app_main(void) level_low.notifyPowerOn(); level_high.notifyPowerOn(); + // A failed GPIO init leaves that pin unconfigured and floating: latch + // the sensor Faulted so isValid() stays false — markFaulted() is on + // the concrete DebouncedLevelSensor by design (not ILevelSensor), so + // it is called here at the wiring site, on the raw objects; safe + // because no other task touches the sensors yet (console registration + // and the main loop come later). Ordered AFTER the boot + // notifyPowerOn() above, which is the deliberate re-arm that clears a + // fault (recovery: PR-14 rail control or an operator power cycle). + if (!level_low_ok) { + level_low_raw.markFaulted(); + ESP_LOGE(TAG, "level LOW sensor GPIO init failed (pin %d) — sensor " + "faulted, readings invalid until a power-on re-arm", + BOARD_PIN_LEVEL_LOW); + } + if (!level_high_ok) { + level_high_raw.markFaulted(); + ESP_LOGE(TAG, "level HIGH sensor GPIO init failed (pin %d) — sensor " + "faulted, readings invalid until a power-on re-arm", + BOARD_PIN_LEVEL_HIGH); + } + // Serial diagnostic REPL (rig testing; contracts/serial-diagnostic.md). // Pump registration is capability-aware: single-pump boards register // exactly the plant pump — `pump reservoir` does not exist there diff --git a/firmware/main/diag_console.cpp b/firmware/main/diag_console.cpp index a649ba8..101719c 100644 --- a/firmware/main/diag_console.cpp +++ b/firmware/main/diag_console.cpp @@ -52,8 +52,8 @@ * * Level sensor command (HIL verification path for feature 006; console * contract in specs/006-level-sensors-ina226/contracts/interfaces.md — the - * output distinguishes "not_yet_valid" from wet/dry, SC-005/FR-012, and - * shows the logical + raw state per sensor): + * output distinguishes "not_yet_valid" from wet/dry, FR-001/FR-003/FR-012, + * and shows the logical + raw state per sensor): * * level # both sensors: logical + raw + validity * @@ -699,14 +699,18 @@ int env_cmd(int argc, char **argv) // --- level command (feature 006 HIL verification path) ------------------- /// One sensor's console word: "not_yet_valid" is a DISTINCT state, never -/// conflated with wet/dry (SC-005/FR-012 — a settling or warming-up sensor -/// must not read as an empty or full reservoir). +/// conflated with wet/dry (FR-001/FR-003/FR-012 — a settling or warming-up +/// sensor must not read as an empty or full reservoir). const char *level_state_str(ILevelSensor &sensor) { // isValid() and isWaterPresent() are separate locked calls; a main-loop // update() interleaving between them can only make a just-valid reading - // report not_yet_valid or vice versa (benign cross-lock race, - // TODO(PR-11) snapshot helper in LockedLevelSensor.h). + // report not_yet_valid, or a reading that lost validity in the gap + // print "dry" — benign either way, because isWaterPresent() returns + // false whenever invalid (ILevelSensor contract): never a stale or + // phantom "water". One-poll diagnostic glitch only; TODO(PR-11): + // migrate to the snapshot helper in LockedLevelSensor.h (PR-14 keeps + // relying on this console path). if (!sensor.isValid()) { return "not_yet_valid"; } diff --git a/firmware/test_apps/host/main/test_ina226.cpp b/firmware/test_apps/host/main/test_ina226.cpp index 9676b68..dc41d88 100644 --- a/firmware/test_apps/host/main/test_ina226.cpp +++ b/firmware/test_apps/host/main/test_ina226.cpp @@ -19,10 +19,11 @@ * asserted big-endian in order, the 5 mΩ/0.5 mA operating point * (CAL = 2048), scaling vectors hand-computed from the SBOS547 formulas * (LSB units, a realistic pump vector, a negative-current vector), - * identity mismatch / absent device → error 1, mid-read bus error → - * error 2 + last-good retention + re-probe recovery, mid-init write - * failure → error 2, live isAvailable() probe, and the LockedPowerSensor - * delegation check. + * identity mismatch / absent device / ACK-then-identity-read failure → + * error 1, mid-read bus error → error 2 + last-good retention + re-probe + * recovery, mid-triple publish-last atomicity, mid-init write failure + * (config leg and calibration leg) → error 2 + full-sequence re-run, live + * isAvailable() probe, and the LockedPowerSensor delegation check. */ #include @@ -191,6 +192,14 @@ void test_mock_word_registers_adjacent_no_collision(void) // byte-map model their MSB/LSB pairs would overwrite each other // (0x02's LSB slot is 0x03's MSB slot). The word overlay keeps each // 16-bit register independent — the model real hardware implements. + // + // This test also pins the surviving half of the mock's loud-failure + // contract: EXACT 2-byte reads of setRegister16()-scripted registers + // are served from the overlay. The other half — a mis-shaped read + // overlapping such a register aborts the process — cannot appear as a + // passing Unity test by construction (abort() kills the test binary); + // it is documented in MockI2cBus.h and exists to make a bad test + // script fail loudly instead of silently reading byte-map zeros. bus.setRegister16(kAddr, kRegBusVoltage, 0x2580); bus.setRegister16(kAddr, kRegPower, 0x0F00); bus.setRegister16(kAddr, kRegCurrent, 0x1F40); @@ -225,6 +234,16 @@ void script_readings(MockI2cBus& bus, uint16_t rawBus, uint16_t rawPower, bus.setRegister16(kAddr, kRegCurrent, rawCurrent); } +/// Number of recorded calls of @p type (probe/write16 sequence counting). +size_t count_calls(const MockI2cBus& bus, MockI2cBus::Call::Type type) +{ + size_t n = 0; + for (const MockI2cBus::Call& call : bus.calls) { + n += call.type == type ? 1 : 0; + } + return n; +} + // --- initialization: write sequence + operating point --------------------- void test_ina226_calibration_operating_point(void) @@ -436,6 +455,59 @@ void test_ina226_mid_init_write_failure_is_error_2(void) TEST_ASSERT_EQUAL(probesBefore + 1, probesAfter); } +void test_ina226_ack_but_identity_read_failure_is_error_1(void) +{ + MockI2cBus bus; + script_identity(bus); + Ina226Sensor sensor(bus, kAddr, kShuntMilliOhm); + + // The device ACKs its address but the identity READ itself fails: the + // device never identified, so this is DELIBERATELY error 1 ("not + // found"), not error 2 ("after identification") — pins the + // classification at Ina226Sensor::probeAndIdentify(). + bus.queueReadOutcome(false); + TEST_ASSERT_FALSE(sensor.initialize()); + TEST_ASSERT_EQUAL_INT(1, sensor.getLastError()); + + // No identity ⇒ the config/calibration writes never happen. + TEST_ASSERT_EQUAL( + 0, count_calls(bus, MockI2cBus::Call::Type::Write16)); + + // Transient: the next attempt (read queue exhausted = success) runs + // the full sequence and recovers. + TEST_ASSERT_TRUE(sensor.initialize()); + TEST_ASSERT_EQUAL_INT(0, sensor.getLastError()); +} + +void test_ina226_calibration_write_failure_is_error_2(void) +{ + MockI2cBus bus; + script_identity(bus); + Ina226Sensor sensor(bus, kAddr, kShuntMilliOhm); + + // The device identified and the CONFIG write succeeded, but the + // CALIBRATION write (second leg of the init sequence) fails: error 2, + // and the driver stays uninitialized. + bus.queueWriteOutcome(true); // config write + bus.queueWriteOutcome(false); // calibration write fails + TEST_ASSERT_FALSE(sensor.initialize()); + TEST_ASSERT_EQUAL_INT(2, sensor.getLastError()); + + // Uninitialized means the next attempt re-runs the FULL sequence: a + // fresh probe + identity AND both writes (config again, not just the + // failed calibration). + const size_t probesBefore = + count_calls(bus, MockI2cBus::Call::Type::Probe); + const size_t writesBefore = + count_calls(bus, MockI2cBus::Call::Type::Write16); + TEST_ASSERT_TRUE(sensor.initialize()); + TEST_ASSERT_EQUAL_INT(0, sensor.getLastError()); + TEST_ASSERT_EQUAL(probesBefore + 1, + count_calls(bus, MockI2cBus::Call::Type::Probe)); + TEST_ASSERT_EQUAL(writesBefore + 2, + count_calls(bus, MockI2cBus::Call::Type::Write16)); +} + void test_ina226_mid_read_bus_error_lastgood_and_recovery(void) { MockI2cBus bus; @@ -479,6 +551,31 @@ void test_ina226_mid_read_bus_error_lastgood_and_recovery(void) TEST_ASSERT_EQUAL(write16Before + 2, write16After); // re-configured } +void test_ina226_mid_triple_read_failure_keeps_full_lastgood_triple(void) +{ + MockI2cBus bus; + script_identity(bus); + script_readings(bus, 0x2580, 0x0F00, 0x1F40); // 12 V / 48 W / 4 A + Ina226Sensor sensor(bus, kAddr, kShuntMilliOhm); + TEST_ASSERT_TRUE(sensor.read()); + + // The device now serves DIFFERENT values (12.5 V / 25 W / 2 A), and + // the failure hits MID-triple: the bus-voltage read succeeds (it sees + // the fresh 12.5 V), then the power read fails. Publication must be + // all-or-nothing (fetch-all-then-publish in Ina226Sensor::read()): + // ALL THREE getters still hold the previous complete triple — + // including bus voltage, whose register read succeeded — never a + // fresh voltage next to a stale power. + script_readings(bus, 0x2710, 0x07D0, 0x0FA0); + bus.queueReadOutcome(true); // bus-voltage read succeeds + bus.queueReadOutcome(false); // power read fails mid-triple + TEST_ASSERT_FALSE(sensor.read()); + TEST_ASSERT_EQUAL_INT(2, sensor.getLastError()); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 12.0f, sensor.getBusVoltage()); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 4.0f, sensor.getCurrent()); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 48.0f, sensor.getPower()); +} + void test_ina226_is_available_live_probe(void) { MockI2cBus bus; @@ -545,8 +642,11 @@ void run_ina226_tests(void) RUN_TEST(test_ina226_negative_current_sign_preserved); RUN_TEST(test_ina226_identity_mismatch_is_error_1); RUN_TEST(test_ina226_absent_device_is_error_1); + RUN_TEST(test_ina226_ack_but_identity_read_failure_is_error_1); RUN_TEST(test_ina226_mid_init_write_failure_is_error_2); + RUN_TEST(test_ina226_calibration_write_failure_is_error_2); RUN_TEST(test_ina226_mid_read_bus_error_lastgood_and_recovery); + RUN_TEST(test_ina226_mid_triple_read_failure_keeps_full_lastgood_triple); RUN_TEST(test_ina226_is_available_live_probe); RUN_TEST(test_locked_power_sensor_delegates); } diff --git a/firmware/test_apps/host/main/test_level_sensor.cpp b/firmware/test_apps/host/main/test_level_sensor.cpp index 7420818..f0e754f 100644 --- a/firmware/test_apps/host/main/test_level_sensor.cpp +++ b/firmware/test_apps/host/main/test_level_sensor.cpp @@ -12,15 +12,19 @@ * * Coverage maps to tasks.md T009 (US1): debounce boundary (change only * after a full window; every flip restarts it), warm-up not-yet-valid, - * settle gating (the 500 ms rev2 case incl. notifyPowerOn() re-arm), + * settle gating (the 500 ms rev2 case incl. notifyPowerOn() re-arm, + * window-opens-at-first-update, chatter inside the settle window), * polarity equivalence (both board polarities produce identical logical * results for the same physical scenario), chatter collapsing to a single * transition, update-stream purity (time without update() changes - * nothing) and the LockedLevelSensor delegation check. T021 (US4) adds - * the MockLevelSensor consumer-style tests (SC-006: all four PR-11 + * nothing), the invalid ⇒ isWaterPresent()-false structural guarantee, + * the markFaulted() latch (GPIO-init-failure wiring path) and the + * LockedLevelSensor delegation check. T021 (US4) adds the + * MockLevelSensor consumer-style tests (SC-006: all four PR-11 * truth-table states across two instances, with coherent validity), and - * T022 the per-board fail-direction truths (docs/parity-checklist.md - * line 97 pinned as host-tested constants). + * T022 the per-board fail-direction truths (docs/parity-checklist.md §3, + * "Pull-up + active-HIGH consequence" item, pinned as host-tested + * constants). * * Timing convention (DebouncedLevelSensor contract): a window of N ms is * complete on the first update() where at least N ms have elapsed since @@ -237,6 +241,12 @@ void test_settle_gating_500ms(void) input.level = true; sensor.update(); // settle window opens at the FIRST update TEST_ASSERT_FALSE(sensor.isValid()); + // rawState() is polarity-INDEPENDENT diagnostics — the one place board + // polarity must NOT be absorbed: the pin is electrically HIGH, so + // rawState() reads true even though active LOW maps HIGH to "water + // absent" (and the sensor is not even valid yet). + TEST_ASSERT_TRUE(sensor.rawState()); + TEST_ASSERT_FALSE(sensor.isWaterPresent()); // 499 ms: still settling — the raw value is not even being timed yet. step(sensor, time, kSettleMs - 1); @@ -250,8 +260,61 @@ void test_settle_gating_500ms(void) TEST_ASSERT_FALSE(sensor.isValid()); step(sensor, time, 1); TEST_ASSERT_TRUE(sensor.isValid()); - // Active LOW: raw HIGH = water absent. + // Active LOW: raw HIGH = water absent — while rawState() still reports + // the electrical level unmapped. TEST_ASSERT_FALSE(sensor.isWaterPresent()); + TEST_ASSERT_TRUE(sensor.rawState()); +} + +void test_settle_window_opens_at_first_update_after_construction(void) +{ + ScriptedInput input; + input.level = true; + FakeTimeProvider time; + DebouncedLevelSensor sensor(input, time, /*activeLow=*/true, + kDebounceMs, kSettleMs); + + // Dead time between CONSTRUCTION and the owner's first poll must not + // be consumed by the settle window: it opens at the first update() + // (same contract the notifyPowerOn() re-arm test pins for that case). + time.advance(10'000); + sensor.update(); // settle window opens HERE, not at construction + step(sensor, time, kSettleMs - 1); + TEST_ASSERT_FALSE(sensor.isValid()); + step(sensor, time, 1); // settle done, warm-up opens + TEST_ASSERT_FALSE(sensor.isValid()); + step(sensor, time, kDebounceMs); + TEST_ASSERT_TRUE(sensor.isValid()); +} + +void test_chatter_during_settle_validity_at_exactly_settle_plus_debounce(void) +{ + ScriptedInput input; + FakeTimeProvider time; + DebouncedLevelSensor sensor(input, time, /*activeLow=*/false, + kDebounceMs, kSettleMs); + + // Raw chatters every 100 ms THROUGHOUT the settle window: pre-settle + // flips neither restart settling nor pre-latch the debounce candidate. + sensor.update(); // settle window opens at t=0 + for (int i = 1; i <= 4; ++i) { // flips observed at t=100..400 + input.level = (i % 2) != 0; + step(sensor, time, 100); + TEST_ASSERT_FALSE(sensor.isValid()); + } + + // Stable from before the settle boundary: warm-up opens at t=500 on + // the then-current raw value, so validity arrives at EXACTLY + // settle + debounce (t=800) — any earlier candidate latch or settle + // restart would move this boundary. + input.level = true; + step(sensor, time, 100); // t=500: settle elapses, warm-up opens + TEST_ASSERT_FALSE(sensor.isValid()); + step(sensor, time, kDebounceMs - 1); // t=799: one short + TEST_ASSERT_FALSE(sensor.isValid()); + step(sensor, time, 1); // t=800 = settle + debounce exactly + TEST_ASSERT_TRUE(sensor.isValid()); + TEST_ASSERT_TRUE(sensor.isWaterPresent()); } void test_notify_power_on_rearms_settle(void) @@ -287,6 +350,104 @@ void test_notify_power_on_rearms_settle(void) TEST_ASSERT_TRUE(sensor.isWaterPresent()); } +// --------------------------------------------------------------------------- +// Invalid ⇒ isWaterPresent() false, structurally (ILevelSensor contract: +// never a stale or phantom value). The active-LOW (rev2) config is the +// sharp case: a naive polarity map of the constructed stableRaw_ = false +// would read !false = "water present" — a phantom. +// --------------------------------------------------------------------------- + +void test_invalid_reads_false_active_low(void) +{ + ScriptedInput input; + input.level = true; // raw HIGH = dry on rev2 + FakeTimeProvider time; + DebouncedLevelSensor sensor(input, time, /*activeLow=*/true, + kDebounceMs, kSettleMs); + + // Freshly constructed and during settle: false, never the phantom. + TEST_ASSERT_FALSE(sensor.isValid()); + TEST_ASSERT_FALSE(sensor.isWaterPresent()); + sensor.update(); // settle window opens + step(sensor, time, kSettleMs - 1); + TEST_ASSERT_FALSE(sensor.isValid()); + TEST_ASSERT_FALSE(sensor.isWaterPresent()); + + // Reach TRACKING with water PRESENT (raw LOW on rev2). + input.level = false; + step(sensor, time, 1); // settle done, warm-up opens on LOW + step(sensor, time, kDebounceMs); // warm-up complete + TEST_ASSERT_TRUE(sensor.isValid()); + TEST_ASSERT_TRUE(sensor.isWaterPresent()); + + // After notifyPowerOn(): invalid again, and the stale "water present" + // must NOT leak — false until re-validated. + sensor.notifyPowerOn(); + TEST_ASSERT_FALSE(sensor.isValid()); + TEST_ASSERT_FALSE(sensor.isWaterPresent()); + sensor.update(); // settle window re-opens + step(sensor, time, kSettleMs - 1); + TEST_ASSERT_FALSE(sensor.isWaterPresent()); + step(sensor, time, 1); + step(sensor, time, kDebounceMs - 1); + TEST_ASSERT_FALSE(sensor.isWaterPresent()); // still warming up + + // Re-validated (raw held LOW throughout): the true reading returns. + step(sensor, time, 1); + TEST_ASSERT_TRUE(sensor.isValid()); + TEST_ASSERT_TRUE(sensor.isWaterPresent()); +} + +// --------------------------------------------------------------------------- +// markFaulted(): the GPIO-init-failure latch (wiring-site concept) +// --------------------------------------------------------------------------- + +void test_faulted_sensor_never_becomes_valid(void) +{ + ScriptedInput input; + input.level = true; // perfectly stable input — must not matter + FakeTimeProvider time; + DebouncedLevelSensor sensor(input, time, /*activeLow=*/false, + kDebounceMs, /*settleMs=*/0); + sensor.markFaulted(); + + // However long and stable the update stream, a faulted sensor stays + // invalid (a floating unconfigured pin must never debounce its way to + // isValid()) and its logical state reads false. + sensor.update(); + for (int i = 0; i < 20; ++i) { + step(sensor, time, 100); // 2 s total ≫ settle + debounce + TEST_ASSERT_FALSE(sensor.isValid()); + TEST_ASSERT_FALSE(sensor.isWaterPresent()); + } + // rawState() diagnostics still follow the pin even while faulted. + TEST_ASSERT_TRUE(sensor.rawState()); +} + +void test_notify_power_on_clears_fault(void) +{ + ScriptedInput input; + input.level = true; + FakeTimeProvider time; + DebouncedLevelSensor sensor(input, time, /*activeLow=*/false, + kDebounceMs, /*settleMs=*/0); + sensor.markFaulted(); + step(sensor, time, 1000); + TEST_ASSERT_FALSE(sensor.isValid()); + + // notifyPowerOn() is the deliberate re-arm (PR-14 rail control / an + // operator power cycle): the fault clears and the normal settle + + // warm-up sequence runs from the next update(). + sensor.notifyPowerOn(); + TEST_ASSERT_FALSE(sensor.isValid()); // re-armed, not instantly valid + sensor.update(); // warm-up opens (settle 0) + step(sensor, time, kDebounceMs - 1); + TEST_ASSERT_FALSE(sensor.isValid()); + step(sensor, time, 1); + TEST_ASSERT_TRUE(sensor.isValid()); + TEST_ASSERT_TRUE(sensor.isWaterPresent()); +} + // --------------------------------------------------------------------------- // Polarity equivalence (FW-5): board polarity is fully absorbed // --------------------------------------------------------------------------- @@ -410,7 +571,8 @@ void test_locked_level_sensor_delegates(void) } // --------------------------------------------------------------------------- -// Fail direction per board (T022, docs/parity-checklist.md line 97): +// Fail direction per board (T022, docs/parity-checklist.md §3, "Pull-up + +// active-HIGH consequence" item): // a disconnected sensor input is pulled HIGH by the pull-up (internal on // both boards, external 10 kΩ additionally on rev2 — research R4). The // resulting LOGICAL state differs per board polarity, and BOTH directions @@ -423,7 +585,8 @@ void test_fail_direction_rev1_disconnected_reads_water_present(void) // rev1 (two-pump node, active HIGH — non-inverting TXS0108E path): // pulled-HIGH = "water present" at every mark ⇒ the reservoir looks // full, so the FILL pump stays off. Fails toward "do not pump" - // (checklist line 97; legacy src/main.cpp:231-233 behavior preserved). + // (checklist §3 "Pull-up + active-HIGH consequence" item; legacy + // src/main.cpp:231-233 behavior preserved). ScriptedInput input; input.level = true; // disconnected: the pull-up pins the raw input HIGH FakeTimeProvider time; @@ -444,7 +607,8 @@ void test_fail_direction_rev2_disconnected_reads_water_absent(void) // pump dry. The opposite direction of rev1 — and exactly as safe, // because the pump topology is opposite: rev1 fills a reservoir // (phantom water = no overfill), rev2 draws from one (phantom - // emptiness = no dry run). Checklist line 97. + // emptiness = no dry run). Checklist §3 "Pull-up + active-HIGH + // consequence" item. ScriptedInput input; input.level = true; // disconnected: the pull-up pins the raw input HIGH FakeTimeProvider time; @@ -533,7 +697,12 @@ void run_level_sensor_tests(void) RUN_TEST(test_tracking_flip_restarts_window); RUN_TEST(test_tracking_flip_back_cancels_pending_change); RUN_TEST(test_settle_gating_500ms); + RUN_TEST(test_settle_window_opens_at_first_update_after_construction); + RUN_TEST(test_chatter_during_settle_validity_at_exactly_settle_plus_debounce); RUN_TEST(test_notify_power_on_rearms_settle); + RUN_TEST(test_invalid_reads_false_active_low); + RUN_TEST(test_faulted_sensor_never_becomes_valid); + RUN_TEST(test_notify_power_on_clears_fault); RUN_TEST(test_polarity_equivalence); RUN_TEST(test_chatter_single_transition); RUN_TEST(test_raw_state_is_undebounced); diff --git a/specs/006-level-sensors-ina226/checklists/hil.md b/specs/006-level-sensors-ina226/checklists/hil.md index e9572ca..97d98d4 100644 --- a/specs/006-level-sensors-ina226/checklists/hil.md +++ b/specs/006-level-sensors-ina226/checklists/hil.md @@ -36,7 +36,7 @@ surfaces in `level` output (window restart behavior; host-tested deterministically — this bench item is a spot check) -## C. Fail direction: disconnect (US4, checklist line 97) +## C. Fail direction: disconnect (US4, parity checklist §3, "Pull-up + active-HIGH consequence" item) - [ ] C1. Disconnect the LOW sensor signal wire mid-run → after ~0.3 s `level` reads `low=water` (pull-up ⇒ HIGH ⇒ rev1 active HIGH = @@ -44,8 +44,8 @@ flapping - [ ] C2. Reconnect → the true state returns within one debounce window - [ ] C3. Confirm the direction is logged/annotated in the parity - checklist line 97 item (rev2's inverted direction is host-tested - only — no rev2 hardware yet) + checklist §3 "Pull-up + active-HIGH consequence" item (rev2's + inverted direction is host-tested only — no rev2 hardware yet) ## D. Pump + console regression (US2, SC-004) diff --git a/specs/006-level-sensors-ina226/contracts/interfaces.md b/specs/006-level-sensors-ina226/contracts/interfaces.md index 7d7189b..0a934b7 100644 --- a/specs/006-level-sensors-ina226/contracts/interfaces.md +++ b/specs/006-level-sensors-ina226/contracts/interfaces.md @@ -22,17 +22,26 @@ Contract: of the update stream, never of wall-clock reads alone. - `isWaterPresent()` is meaningful only when `isValid()` — consumers gate on validity (PR-11's truth table treats invalid as "do not act"). Before the first - stable window and during settle gating: `isValid()` false. + stable window and during settle gating: `isValid()` false, and + `isWaterPresent()` returns false whenever invalid (never a stale or phantom + value). - Polarity is fully absorbed here via board configuration (FW-5); no consumer ever sees a raw-polarity value except through `rawState()` (diagnostics only). - `notifyPowerOn()` re-arms settle gating (rev2 ≥500 ms; rev1 0 ms). Rail control - itself is PR-14 (CP1 decision A) — app_main calls this once at boot on rev2. + itself is PR-14 (CP1 decision A) — app_main calls this once at boot on BOTH + boards (no-op effect on rev1, where settle = 0 leaves only the standard + debounce warm-up). - Debounce: reported state changes only after `BOARD_LEVEL_DEBOUNCE_MS` of raw stability; any flip restarts the window (deliberate divergence from legacy's bare reads — parity-checklist §6 entry). -- Fail direction (pinned by host tests, checklist line 97): disconnected input is +- Fail direction (pinned by host tests, `docs/parity-checklist.md` §3, "Pull-up + + active-HIGH consequence" item): disconnected input is pulled HIGH ⇒ rev1 reads "water present" (fill pump stays off), rev2 reads "water absent" (drawing node does not pump). Both fail safe for their topology. +- Fault latch (implementation-level, NOT part of this interface): + `DebouncedLevelSensor::markFaulted()` pins `isValid()` false when the raw + input's GPIO init failed; app_main calls it at the wiring site (concrete type + known there). `notifyPowerOn()` is the deliberate re-arm that clears it. ## `IPowerSensor` (interfaces component, header-only, no IDF includes) @@ -95,6 +104,15 @@ as `LockedEnvironmentalSensor` (TODO(PR-11) snapshot helper applies here too). - `EspI2cBus` and `MockI2cBus` updated accordingly; `MockI2cBus` records 16-bit writes into the per-address byte map (big-endian) so existing byte assertions remain valid. BME280 call sites are untouched. +- `MockI2cBus` word-register overlay: `setRegister16()` scripts pointer-addressed + 16-bit registers (INA226 model — adjacent word registers like 0x02/0x03/0x04 + never collide), served ONLY by exact 2-byte reads; it is deliberately NOT + mirrored into the byte map. A read that is not an exact 2-byte hit but whose + range overlaps such a byte-incoherent word register fails loudly + (`abort()` with a stderr message, host-only code) instead of silently serving + byte-map zeros. `writeRegister16()` keeps both models coherent (byte map + big-endian + overlay), so byte-level reads of driver-written registers stay + valid. ## Board contract (board.h) @@ -102,6 +120,9 @@ Per data-model.md table. Enforcement: `BOARD_HAS_RESERVOIR_PUMP=0` ⇒ `BOARD_PIN_RESERVOIR_PUMP` undefined (compile error on unguarded use — RS485-DE pattern); consistency asserts flag⇒pin; existing reservoir-pin sanity checks flag-guarded; level pins distinct from each other/I2C/RS485 pins (compile-time). +Kconfig: `WS_INA226_SHUNT_MILLIOHM` (`main/Kconfig.projbuild`) carries +`depends on BOARD_REV2` — the option does not exist in rev1 configurations +(implementation decision, matching the FR-011 compile-out). ## Console commands (main/diag_console, thin wrappers) @@ -110,7 +131,8 @@ level # both sensors: logical + raw + validity (both boards) power # INA226 V/I/P or ERROR + hint (BOARD_HAS_INA226 only) ``` -- `level` output must distinguish "not yet valid" from wet/dry (SC-005/FR-012). +- `level` output must distinguish "not yet valid" from wet/dry + (FR-001/FR-003/FR-012). - `power` failure output distinguishes error 1 (not found) from 2 (read failed). - `pump reservoir ...` registration is compiled out on `BOARD_HAS_RESERVOIR_PUMP=0` builds — rev2 console lists exactly one pump (PR-14 contract). @@ -121,7 +143,10 @@ Function-local statics after `pumps_force_off()` (which becomes capability-aware only existing pumps are forced off — the invariant "every pump that exists is OFF first" is unchanged). Level sensors: two `GpioLevelSensor` + `DebouncedLevelSensor` + `Locked*` wrappers; `update()` called from the 10 Hz main loop; `notifyPowerOn()` -once at boot on rev2. INA226 (rev2): `Ina226Sensor` on the SAME `EspI2cBus` +once at boot on both boards (no-op effect on rev1, settle = 0); a failed +`GpioLevelSensor::initialize()` latches the corresponding sensor via +`markFaulted()` (per-sensor, logged separately). INA226 (rev2): `Ina226Sensor` +on the SAME `EspI2cBus` instance as the BME280 (bus-sharing contract from PR-03 — never a second bus). ## Deliberate divergences from legacy (parity-checklist §6 candidates) diff --git a/specs/006-level-sensors-ina226/research.md b/specs/006-level-sensors-ina226/research.md index cd58f49..ae1cebd 100644 --- a/specs/006-level-sensors-ina226/research.md +++ b/specs/006-level-sensors-ina226/research.md @@ -47,8 +47,9 @@ with a settle duration and starts gated): readings report invalid until on — constructor starts ungated... rev1 also gets debounce warm-up, which subsumes settle=0), rev2 = **500 ms** (FW-3). Rail *control* (IO25/`SENS_PWR_EN`) is PR-14; this PR only guarantees the abstraction honors a power-on notification, and -app_main on rev2 calls it once at boot (rail is on by default at power-up per the -rev2 design; PR-14 wires real switching to it). +app_main calls it once at boot on BOTH boards (no-op effect on rev1, where +settle = 0 leaves only the standard debounce warm-up; on rev2 the rail is on by +default at power-up per the rev2 design; PR-14 wires real switching to it). **Rationale**: encodes the FW-3 invariant now so PR-14 is wiring, not redesign; rev1 unaffected.