diff --git a/.github/workflows/firmware-build.yml b/.github/workflows/firmware-build.yml index 07b0d98..79ccd2f 100644 --- a/.github/workflows/firmware-build.yml +++ b/.github/workflows/firmware-build.yml @@ -52,6 +52,7 @@ jobs: test -f firmware/build/wateringsystem.bin test -f firmware/build/bootloader/bootloader.bin test -f firmware/build/partition_table/partition-table.bin + test -f firmware/build/ota_data_initial.bin - name: Upload binaries uses: actions/upload-artifact@v4 @@ -62,3 +63,26 @@ jobs: firmware/build/wateringsystem.bin firmware/build/partition_table/partition-table.bin firmware/build/bootloader/bootloader.bin + firmware/build/ota_data_initial.bin + + # Host tests: the pump enforcement logic runs natively on the IDF linux + # preview target. The test executable's exit code equals the Unity + # failure count, so running it is the gate (no log scraping needed). + host-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Build and run host tests (linux target) + uses: espressif/esp-idf-ci-action@9d38657f3d789ca759b2b37aaf5ceffbc42c4f0d # v1 + with: + esp_idf_version: v6.0.1 + # The action defaults IDF_TARGET to esp32, which makes + # `set-target linux` abort ("not consistent with target in the + # environment"). Match the env to the linux preview target. + target: linux + path: firmware + command: cd test_apps/host && idf.py --preview set-target linux && idf.py build && ./build/pump_host_tests.elf diff --git a/.gitignore b/.gitignore index 035f5a1..4b88ed2 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ firmware/managed_components/ # Session dialog documents (not part of any PR) docs/phase0-plan.md docs/phase0-review.md +docs/checkpoints/ # Claude Code .claude/ diff --git a/.specify/feature.json b/.specify/feature.json new file mode 100644 index 0000000..d7c1352 --- /dev/null +++ b/.specify/feature.json @@ -0,0 +1,3 @@ +{ + "feature_directory": "specs/002-pump-gpio-board" +} diff --git a/firmware/CLAUDE.md b/firmware/CLAUDE.md index 680db1c..3e750e3 100644 --- a/firmware/CLAUDE.md +++ b/firmware/CLAUDE.md @@ -26,6 +26,18 @@ overlays are only applied when `sdkconfig` is regenerated. CI is main-only, feature branches build via PR or manual dispatch. Both boards must stay green. +### Host tests (linux preview target) + +Pump enforcement logic is unit-tested natively, no ESP32 needed. The test +executable's exit code equals the Unity failure count (CI gate, job +`host-test`): + +```bash +cd firmware/test_apps/host +docker run --rm -v "$PWD":/project -w /project espressif/idf:v6.0.1 bash -c \ + "idf.py --preview set-target linux && idf.py build && ./build/pump_host_tests.elf" +``` + ## Directory structure ``` @@ -39,16 +51,52 @@ firmware/ ├── Dockerfile # Pins espressif/idf:v6.0.1 ├── main/ │ ├── app_main.cpp # Entry point — pumps forced OFF first, always +│ ├── diag_console.cpp/.h # esp_console UART REPL (prompt "ws>") │ ├── Kconfig.projbuild # Board revision choice │ └── idf_component.yml # Pinned managed deps (esp-modbus, littlefs) -└── components/ - └── board/ # Board abstraction (header-only) - └── include/board/board.h +├── components/ +│ ├── board/ # Board abstraction (header-only) +│ │ └── include/board/board.h +│ ├── interfaces/ # Header-only, NO IDF deps (host-includable) +│ │ └── include/interfaces/ # IActuator, IWaterPump, ITimeProvider +│ └── actuators/ # Pump drivers +│ ├── include/actuators/ # WaterPump (pure C++ logic), GpioWaterPump, +│ │ # EspTimeProvider (esp32-only header), +│ │ # testing/ (MockWaterPump, FakeTimeProvider) +│ └── src/ # GpioWaterPump.cpp excluded on linux target +└── test_apps/ + └── host/ # Host test app (linux preview target, Unity) ``` Future components (drivers, controllers, web server) are added as siblings under `components/`, one concern per component. +## Pump actuator layer + +All timing/safety logic (timed runs, hard 300 s max-runtime cap, runtime +statistics, stop reasons) lives in the pure C++ `WaterPump` base class, +driven by an injected `ITimeProvider` and a polled `update()` (main loop, +10 Hz). The only hardware touchpoint is `applyOutput(bool)`, implemented by +`GpioWaterPump` (active-HIGH MOSFET gate, OFF re-asserted glitch-free at +init). Host-tested code must never call `esp_timer` directly — it is not +simulated on the linux target; inject time instead. Concurrency: `WaterPump` +is unsynchronized by design — any pump accessed from more than one task +(e.g. main loop + console REPL) must be wrapped in `LockedWaterPump` and +accessed only through the wrapper. + +## Serial diagnostic console + +`main/diag_console.cpp` starts an `esp_console` UART REPL (prompt `ws>`, +same UART as logs, 115200 baud). Command grammar and exact response formats +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 +``` + ## Board abstraction Two board revisions exist, selected via Kconfig (`main/Kconfig.projbuild`): diff --git a/firmware/README.md b/firmware/README.md index ba495df..e8e60e1 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -40,6 +40,44 @@ Pin mappings and board feature flags live in `components/board/include/board/board.h` behind `CONFIG_BOARD_REV1_DEVKIT` / `CONFIG_BOARD_REV2`. +## Host tests (no hardware needed) + +The pump enforcement logic (timed runs, hard 300 s max-runtime cap, runtime +statistics) is pure C++ and is unit-tested natively on the ESP-IDF **linux +preview target** with the IDF-bundled Unity framework. From the repository: + +```bash +cd firmware/test_apps/host +docker run --rm -v "$PWD":/project -w /project espressif/idf:v6.0.1 bash -c \ + "idf.py --preview set-target linux && idf.py build && ./build/pump_host_tests.elf" +echo $? # 0 = all tests pass; >0 = number of failures +``` + +> **Note (macOS/OneDrive):** copy the tree to `/tmp` before mounting in +> Docker — the OneDrive path cannot be docker-mounted. + +Coverage: max-runtime enforcement, duration self-stop, rejected starts +(0 s, > 300 s, already running), stop-when-stopped no-op, paired output +transitions and runtime statistics — all on a deterministic fake clock +(no sleeps). CI runs the same suite in the `host-test` job; the executable's +exit code equals the Unity failure count. + +## Serial diagnostic console + +The firmware starts an `esp_console` REPL on UART0 (same port as the logs, +115200 baud, prompt `ws>`) for bench-rig testing: + +``` +pump start # timed run; 1..300 +pump stop +pump status +pump status # both pumps +``` + +`help` lists all commands. Exact response formats are specified in +`specs/002-pump-gpio-board/contracts/serial-diagnostic.md`; the HIL +acceptance checklist is `specs/002-pump-gpio-board/checklists/hil.md`. + ## Flash and monitor (initial bring-up only) Routine updates will be delivered via OTA (phase 5). For initial bring-up, diff --git a/firmware/components/actuators/CMakeLists.txt b/firmware/components/actuators/CMakeLists.txt new file mode 100644 index 0000000..d9ad621 --- /dev/null +++ b/firmware/components/actuators/CMakeLists.txt @@ -0,0 +1,21 @@ +# actuators — pump driver layer. +# +# WaterPump.cpp is pure C++ (all timing/safety logic) and builds on every +# target, including the linux preview target used by the host test suite. +# GpioWaterPump.cpp is the only hardware touchpoint (gpio_set_level) and is +# excluded — together with its esp_driver_gpio dependency — when building +# for linux (research.md D2). +if(${IDF_TARGET} STREQUAL "linux") + idf_component_register( + SRCS "src/WaterPump.cpp" + INCLUDE_DIRS "include" + REQUIRES interfaces + ) +else() + idf_component_register( + SRCS "src/WaterPump.cpp" "src/GpioWaterPump.cpp" + INCLUDE_DIRS "include" + REQUIRES interfaces + PRIV_REQUIRES esp_driver_gpio + ) +endif() diff --git a/firmware/components/actuators/include/actuators/EspTimeProvider.h b/firmware/components/actuators/include/actuators/EspTimeProvider.h new file mode 100644 index 0000000..7091c3e --- /dev/null +++ b/firmware/components/actuators/include/actuators/EspTimeProvider.h @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file EspTimeProvider.h + * @brief Target (esp32) monotonic clock backed by esp_timer. + * + * ESP32-ONLY: esp_timer is not simulated on the IDF linux preview target, + * so this header must never be included from host-tested code. Host tests + * use actuators/testing/FakeTimeProvider.h instead (research.md D1/D3). + */ + +#ifndef WATERINGSYSTEM_ACTUATORS_ESPTIMEPROVIDER_H +#define WATERINGSYSTEM_ACTUATORS_ESPTIMEPROVIDER_H + +#include + +#include "esp_timer.h" +#include "interfaces/ITimeProvider.h" + +/** + * @brief Monotonic millisecond clock from esp_timer (microsecond source). + */ +class EspTimeProvider : public ITimeProvider { +public: + int64_t nowMs() override + { + return esp_timer_get_time() / 1000; + } +}; + +#endif /* WATERINGSYSTEM_ACTUATORS_ESPTIMEPROVIDER_H */ diff --git a/firmware/components/actuators/include/actuators/GpioWaterPump.h b/firmware/components/actuators/include/actuators/GpioWaterPump.h new file mode 100644 index 0000000..23d06d0 --- /dev/null +++ b/firmware/components/actuators/include/actuators/GpioWaterPump.h @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file GpioWaterPump.h + * @brief GPIO-backed pump driver (N-channel MOSFET gate, active HIGH). + * + * ESP32-ONLY: excluded from the linux-target build together with the + * esp_driver_gpio dependency (see this component's CMakeLists.txt). + * All timing/safety logic lives in the WaterPump base class; this class + * only maps applyOutput() onto gpio_set_level(). + */ + +#ifndef WATERINGSYSTEM_ACTUATORS_GPIOWATERPUMP_H +#define WATERINGSYSTEM_ACTUATORS_GPIOWATERPUMP_H + +#include + +#include "driver/gpio.h" + +#include "actuators/WaterPump.h" +#include "interfaces/ITimeProvider.h" + +/** + * @brief WaterPump whose output drives a GPIO pin (active HIGH). + */ +class GpioWaterPump : public WaterPump { +public: + /** + * @brief Construct a GPIO pump driver. + * + * @param pin MOSFET gate GPIO (from board.h, never hard-coded). + * @param name Identity for logs/diagnostics. + * @param timeProvider Injected monotonic clock. + * @param maxRunTimeMs Hard cap on a single run, in milliseconds. + */ + GpioWaterPump(gpio_num_t pin, std::string name, + ITimeProvider& timeProvider, + int64_t maxRunTimeMs = kDefaultMaxRunTimeMs); + + /** + * @brief Configure the GPIO and force the pump OFF. + * + * Drives the output level to 0 BEFORE enabling the output driver so the + * pin never glitches high (same glitch-free order as the app_main boot + * fail-safe), then re-asserts OFF through the base class. + */ + bool initialize() override; + +protected: + bool applyOutput(bool on) override; + +private: + gpio_num_t pin_; +}; + +#endif /* WATERINGSYSTEM_ACTUATORS_GPIOWATERPUMP_H */ diff --git a/firmware/components/actuators/include/actuators/LockedWaterPump.h b/firmware/components/actuators/include/actuators/LockedWaterPump.h new file mode 100644 index 0000000..33629a2 --- /dev/null +++ b/firmware/components/actuators/include/actuators/LockedWaterPump.h @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file LockedWaterPump.h + * @brief Mutex-serializing IWaterPump decorator (header-only). + * + * WHY THIS EXISTS: the pump objects are accessed from two FreeRTOS tasks — + * the main task polls update() at 10 Hz while the esp_console REPL task + * (diag_console) calls runFor()/stop()/isRunning()/getCurrentRunTimeMs(). + * WaterPump itself is deliberately unsynchronized pure C++ (host-testable), + * so its plain bool/int64_t members would race: e.g. runFor() sets + * running_ = true before runStartedAtMs_, and a concurrently running + * update() could observe a stale start time and force a MaxRuntimeForced + * stop on a fresh start; torn int64 reads are also possible on 32-bit + * Xtensa. This decorator wraps an IWaterPump and takes a mutex around + * every interface call, serializing all access. + * + * USAGE RULE: once a pump is wrapped, the underlying pump must ONLY be + * accessed through the wrapper — every call site (initialize, update, + * console registration, ...) goes through the LockedWaterPump, never + * through the wrapped object directly. + * + * Pure C++ ( is available via pthread on ESP-IDF and on the linux + * preview target), so the decorator is host-testable. + */ + +#ifndef WATERINGSYSTEM_ACTUATORS_LOCKEDWATERPUMP_H +#define WATERINGSYSTEM_ACTUATORS_LOCKEDWATERPUMP_H + +#include +#include +#include + +#include "interfaces/IWaterPump.h" + +/** + * @brief IWaterPump decorator that serializes every call with a mutex. + * + * Composition, not inheritance from WaterPump: the base class stays pure + * (no locking) and the existing host tests are unchanged. The wrapped pump + * must outlive this object. + */ +class LockedWaterPump : public IWaterPump { +public: + /// Wrap @p pump; the wrapped pump must outlive this object. + explicit LockedWaterPump(IWaterPump& pump) : pump_(pump) {} + + LockedWaterPump(const LockedWaterPump&) = delete; + LockedWaterPump& operator=(const LockedWaterPump&) = delete; + + // IActuator + bool initialize() override + { + std::lock_guard lock(mutex_); + return pump_.initialize(); + } + + bool isAvailable() const override + { + std::lock_guard lock(mutex_); + return pump_.isAvailable(); + } + + const std::string& getName() const override + { + std::lock_guard lock(mutex_); + return pump_.getName(); + } + + int getLastError() const override + { + std::lock_guard lock(mutex_); + return pump_.getLastError(); + } + + // IWaterPump + bool runFor(int durationS) override + { + std::lock_guard lock(mutex_); + return pump_.runFor(durationS); + } + + bool stop() override + { + std::lock_guard lock(mutex_); + return pump_.stop(); + } + + bool isRunning() const override + { + std::lock_guard lock(mutex_); + return pump_.isRunning(); + } + + void update() override + { + std::lock_guard lock(mutex_); + pump_.update(); + } + + int64_t getCurrentRunTimeMs() const override + { + std::lock_guard lock(mutex_); + return pump_.getCurrentRunTimeMs(); + } + + int64_t getAccumulatedRunTimeMs() const override + { + std::lock_guard lock(mutex_); + return pump_.getAccumulatedRunTimeMs(); + } + + StopReason getLastStopReason() const override + { + std::lock_guard lock(mutex_); + return pump_.getLastStopReason(); + } + +private: + IWaterPump& pump_; + mutable std::mutex mutex_; +}; + +#endif /* WATERINGSYSTEM_ACTUATORS_LOCKEDWATERPUMP_H */ diff --git a/firmware/components/actuators/include/actuators/WaterPump.h b/firmware/components/actuators/include/actuators/WaterPump.h new file mode 100644 index 0000000..abaef10 --- /dev/null +++ b/firmware/components/actuators/include/actuators/WaterPump.h @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file WaterPump.h + * @brief Pure C++ pump base class: ALL timing and safety logic. + * + * Template-method design (research.md D2): timed-run state machine, + * hard max-runtime enforcement and runtime statistics live here, driven by + * an injected monotonic clock (ITimeProvider) and a polled update(). The + * single hardware touchpoint is the pure virtual applyOutput(bool), so the + * REAL enforcement logic is exercised on the host via MockWaterPump + + * FakeTimeProvider. + * + * This class MUST NOT call esp_timer or any hardware API directly — it is + * compiled and tested on the IDF linux preview target. + */ + +#ifndef WATERINGSYSTEM_ACTUATORS_WATERPUMP_H +#define WATERINGSYSTEM_ACTUATORS_WATERPUMP_H + +#include +#include + +#include "interfaces/ITimeProvider.h" +#include "interfaces/IWaterPump.h" + +/** + * @brief Pump state machine with max-runtime enforcement (data-model.md). + * + * Invariants (host-tested): + * 1. Output transitions are exactly paired: every ON has exactly one OFF. + * 2. initialize() drives the output OFF before any other action. + * 3. No code path keeps the output ON past maxRunTime by more than one + * update() poll. + * 4. Rejected runFor() calls cause no output change and no state change. + */ +class WaterPump : public IWaterPump { +public: + /// Hard cap on a single run (300 s) — constexpr until NVS config (PR-06). + static constexpr int64_t kDefaultMaxRunTimeMs = 300'000; + + /** + * @brief Construct a pump. + * + * @param name Identity for logs/diagnostics ("plant", "reservoir"). + * @param timeProvider Injected monotonic millisecond clock; must outlive + * this object. + * @param maxRunTimeMs Hard cap on a single run, in milliseconds. + */ + WaterPump(std::string name, ITimeProvider& timeProvider, + int64_t maxRunTimeMs = kDefaultMaxRunTimeMs); + + ~WaterPump() override = default; + + WaterPump(const WaterPump&) = delete; + WaterPump& operator=(const WaterPump&) = delete; + + // IActuator + bool initialize() override; + bool isAvailable() const override; + const std::string& getName() const override; + int getLastError() const override; + + // IWaterPump + bool runFor(int durationS) override; + bool stop() override; + bool isRunning() const override; + void update() override; + int64_t getCurrentRunTimeMs() const override; + int64_t getAccumulatedRunTimeMs() const override; + StopReason getLastStopReason() const override; + + /// Hard cap for this instance (for diagnostics/error messages). + int64_t getMaxRunTimeMs() const { return maxRunTimeMs_; } + +protected: + /** + * @brief Drive the physical output. The ONLY hardware touchpoint. + * + * @param on true = pump on, false = pump off. + * @return true on success, false on hardware error. + */ + virtual bool applyOutput(bool on) = 0; + + /// Record a hardware/driver error code (0 clears). + void setLastError(int error) { lastError_ = error; } + +private: + /// Transition Running -> Stopped: paired applyOutput(false), statistics. + void stopWith(StopReason reason); + + std::string name_; + ITimeProvider& timeProvider_; + int64_t maxRunTimeMs_; + + bool initialized_ = false; + bool running_ = false; + int64_t runStartedAtMs_ = 0; + int64_t runDurationMs_ = 0; + int64_t accumulatedRunTimeMs_ = 0; + StopReason lastStopReason_ = StopReason::None; + int lastError_ = 0; +}; + +#endif /* WATERINGSYSTEM_ACTUATORS_WATERPUMP_H */ diff --git a/firmware/components/actuators/include/actuators/testing/FakeTimeProvider.h b/firmware/components/actuators/include/actuators/testing/FakeTimeProvider.h new file mode 100644 index 0000000..907f577 --- /dev/null +++ b/firmware/components/actuators/include/actuators/testing/FakeTimeProvider.h @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file FakeTimeProvider.h + * @brief Deterministic fake clock for host tests (header-only). + * + * Time only moves when the test calls advance() — no sleeps, fully + * deterministic enforcement testing. Never compiled into target builds + * (only included from test code). + */ + +#ifndef WATERINGSYSTEM_ACTUATORS_TESTING_FAKETIMEPROVIDER_H +#define WATERINGSYSTEM_ACTUATORS_TESTING_FAKETIMEPROVIDER_H + +#include + +#include "interfaces/ITimeProvider.h" + +/** + * @brief Manually advanced monotonic clock. + */ +class FakeTimeProvider : public ITimeProvider { +public: + /// Start at an arbitrary (non-zero) epoch to catch 0-assumptions. + explicit FakeTimeProvider(int64_t startMs = 1'000'000) : nowMs_(startMs) {} + + int64_t nowMs() override { return nowMs_; } + + /// Move the clock forward by ms (monotonic: ms must be >= 0). + void advance(int64_t ms) { nowMs_ += ms; } + +private: + int64_t nowMs_; +}; + +#endif /* WATERINGSYSTEM_ACTUATORS_TESTING_FAKETIMEPROVIDER_H */ diff --git a/firmware/components/actuators/include/actuators/testing/MockWaterPump.h b/firmware/components/actuators/include/actuators/testing/MockWaterPump.h new file mode 100644 index 0000000..109c1c7 --- /dev/null +++ b/firmware/components/actuators/include/actuators/testing/MockWaterPump.h @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file MockWaterPump.h + * @brief Host-test pump: records every applyOutput() call (header-only). + * + * The mock overrides ONLY the hardware touchpoint; all timing/safety logic + * under test is the REAL WaterPump base class code (research.md D2). + * Never compiled into target builds (only included from test code). + */ + +#ifndef WATERINGSYSTEM_ACTUATORS_TESTING_MOCKWATERPUMP_H +#define WATERINGSYSTEM_ACTUATORS_TESTING_MOCKWATERPUMP_H + +#include + +#include "actuators/WaterPump.h" + +/** + * @brief WaterPump that records output transitions for invariant checks. + * + * outputCalls holds every applyOutput() argument in call order, e.g. a full + * init/start/stop cycle yields {false, true, false}. + */ +class MockWaterPump : public WaterPump { +public: + using WaterPump::WaterPump; // same constructors as the base + + /// Every applyOutput() argument, in call order. + std::vector outputCalls; + +protected: + bool applyOutput(bool on) override + { + outputCalls.push_back(on); + return true; + } +}; + +#endif /* WATERINGSYSTEM_ACTUATORS_TESTING_MOCKWATERPUMP_H */ diff --git a/firmware/components/actuators/src/GpioWaterPump.cpp b/firmware/components/actuators/src/GpioWaterPump.cpp new file mode 100644 index 0000000..050bc4a --- /dev/null +++ b/firmware/components/actuators/src/GpioWaterPump.cpp @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file GpioWaterPump.cpp + * @brief GPIO pump driver implementation (esp32 targets only). + * + * Error handling is explicit (not ESP_ERROR_CHECK): pump output control is + * safety-relevant and must be checked regardless of the configured + * assertion level (ESP_ERROR_CHECK degrades to a no-op under NDEBUG). + */ + +#include "actuators/GpioWaterPump.h" + +#include + +#include "esp_log.h" + +static const char *TAG = "gpiowaterpump"; + +GpioWaterPump::GpioWaterPump(gpio_num_t pin, std::string name, + ITimeProvider& timeProvider, + int64_t maxRunTimeMs) + : WaterPump(std::move(name), timeProvider, maxRunTimeMs), + pin_(pin) +{ +} + +bool GpioWaterPump::initialize() +{ + // Set the output level to 0 BEFORE switching the pin to output mode, so + // the pin never glitches high when the output driver is enabled (same + // order as the app_main boot fail-safe). + esp_err_t err = gpio_set_level(pin_, 0); + if (err != ESP_OK) { + ESP_LOGE(TAG, "%s: gpio_set_level(%d, 0) failed: %s", + getName().c_str(), static_cast(pin_), + esp_err_to_name(err)); + setLastError(err); + return false; + } + + const gpio_config_t cfg = { + .pin_bit_mask = (1ULL << static_cast(pin_)), + .mode = GPIO_MODE_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + err = gpio_config(&cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "%s: gpio_config(%d) failed: %s", + getName().c_str(), static_cast(pin_), + esp_err_to_name(err)); + setLastError(err); + return false; + } + + // Base class re-asserts OFF via applyOutput(false) and arms the state + // machine. + return WaterPump::initialize(); +} + +bool GpioWaterPump::applyOutput(bool on) +{ + // Active HIGH: MOSFET gate, level 1 = pump on. + const esp_err_t err = gpio_set_level(pin_, on ? 1 : 0); + if (err != ESP_OK) { + ESP_LOGE(TAG, "%s: gpio_set_level(%d, %d) failed: %s", + getName().c_str(), static_cast(pin_), on ? 1 : 0, + esp_err_to_name(err)); + setLastError(err); + return false; + } + return true; +} diff --git a/firmware/components/actuators/src/WaterPump.cpp b/firmware/components/actuators/src/WaterPump.cpp new file mode 100644 index 0000000..7a16490 --- /dev/null +++ b/firmware/components/actuators/src/WaterPump.cpp @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file WaterPump.cpp + * @brief Pump state machine implementation (pure C++, host-testable). + * + * 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. + */ + +#include "actuators/WaterPump.h" + +#include + +#include "esp_log.h" + +static const char *TAG = "waterpump"; + +WaterPump::WaterPump(std::string name, ITimeProvider& timeProvider, + int64_t maxRunTimeMs) + : name_(std::move(name)), + timeProvider_(timeProvider), + maxRunTimeMs_(maxRunTimeMs) +{ +} + +bool WaterPump::initialize() +{ + // Boot fail-safe chain: drive the output OFF before anything else. + // Idempotent — safe to call again at any time. + const bool ok = applyOutput(false); + running_ = false; + if (!ok) { + ESP_LOGE(TAG, "%s: initialize failed to drive output OFF", + name_.c_str()); + initialized_ = false; + return false; + } + initialized_ = true; + return true; +} + +bool WaterPump::isAvailable() const +{ + return initialized_ && lastError_ == 0; +} + +const std::string& WaterPump::getName() const +{ + return name_; +} + +int WaterPump::getLastError() const +{ + return lastError_; +} + +bool WaterPump::runFor(int durationS) +{ + // Rejections cause no output change and no state change (invariant 4). + if (durationS <= 0) { + ESP_LOGW(TAG, "%s: runFor rejected: duration %d s <= 0 " + "(no indefinite runs)", name_.c_str(), durationS); + return false; + } + const int64_t durationMs = static_cast(durationS) * 1000; + if (durationMs > maxRunTimeMs_) { + ESP_LOGW(TAG, "%s: runFor rejected: duration %d s exceeds max %lld s " + "(no silent clamping)", name_.c_str(), durationS, + static_cast(maxRunTimeMs_ / 1000)); + return false; + } + if (running_) { + ESP_LOGW(TAG, "%s: runFor rejected: already running " + "(clock not restarted)", name_.c_str()); + return false; + } + + if (!applyOutput(true)) { + // Hardware failure on the way ON: force OFF for safety; the pump + // never enters the Running state. + ESP_LOGE(TAG, "%s: failed to switch output ON", name_.c_str()); + applyOutput(false); + return false; + } + running_ = true; + runStartedAtMs_ = timeProvider_.nowMs(); + runDurationMs_ = durationMs; + ESP_LOGI(TAG, "%s: running for %d s", name_.c_str(), durationS); + return true; +} + +bool WaterPump::stop() +{ + if (!running_) { + // Stopping a stopped pump is a successful no-op. + return true; + } + stopWith(StopReason::Commanded); + return true; +} + +bool WaterPump::isRunning() const +{ + return running_; +} + +void WaterPump::update() +{ + if (!running_) { + return; + } + const int64_t elapsedMs = timeProvider_.nowMs() - runStartedAtMs_; + + // Max-runtime check FIRST: when a 300 s run hits the 300 s cap, the + // observable reason is MaxRuntimeForced (contract HIL item 4). + if (elapsedMs >= maxRunTimeMs_) { + ESP_LOGE(TAG, "%s: max runtime %lld s reached — forcing stop", + name_.c_str(), + static_cast(maxRunTimeMs_ / 1000)); + stopWith(StopReason::MaxRuntimeForced); + return; + } + if (elapsedMs >= runDurationMs_) { + stopWith(StopReason::DurationElapsed); + } +} + +int64_t WaterPump::getCurrentRunTimeMs() const +{ + if (!running_) { + return 0; + } + return timeProvider_.nowMs() - runStartedAtMs_; +} + +int64_t WaterPump::getAccumulatedRunTimeMs() const +{ + return accumulatedRunTimeMs_; +} + +StopReason WaterPump::getLastStopReason() const +{ + return lastStopReason_; +} + +void WaterPump::stopWith(StopReason reason) +{ + // Paired transition (invariant 1): exactly one OFF per ON. Even if the + // hardware write fails, the state machine stops — never the other way + // around (fail towards OFF). + if (!applyOutput(false)) { + ESP_LOGE(TAG, "%s: failed to switch output OFF", name_.c_str()); + } + const int64_t ranMs = timeProvider_.nowMs() - runStartedAtMs_; + accumulatedRunTimeMs_ += ranMs; + running_ = false; + lastStopReason_ = reason; + ESP_LOGI(TAG, "%s: stopped after %lld ms", name_.c_str(), + static_cast(ranMs)); +} diff --git a/firmware/components/board/include/board/board.h b/firmware/components/board/include/board/board.h index ef52f0f..087750c 100644 --- a/firmware/components/board/include/board/board.h +++ b/firmware/components/board/include/board/board.h @@ -54,6 +54,13 @@ /* Pump current monitoring */ #define BOARD_HAS_INA226 0 +/* Status LED */ +#define BOARD_PIN_STATUS_LED 2 + +/* Buttons (manual watering trigger, WiFi config/AP mode) */ +#define BOARD_PIN_BTN_MANUAL 5 +#define BOARD_PIN_BTN_CONFIG 18 + #elif CONFIG_BOARD_REV2 /* ------------------------------------------------------------------------ @@ -91,8 +98,47 @@ /* 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 +/* Status LED */ +#define BOARD_PIN_STATUS_LED 2 // TODO(SYNC1): final rev2 pin map frozen at hardware sync 1 + +/* Buttons (manual watering trigger, WiFi config/AP mode) */ +#define BOARD_PIN_BTN_MANUAL 5 // TODO(SYNC1): final rev2 pin map frozen at hardware sync 1 +#define BOARD_PIN_BTN_CONFIG 18 // TODO(SYNC1): final rev2 pin map frozen at hardware sync 1 + #else #error "No board selected: enable CONFIG_BOARD_REV1_DEVKIT or CONFIG_BOARD_REV2" #endif +/* ------------------------------------------------------------------------ + * Compile-time board sanity checks (preprocessor — the values are macros). + * A wrong or inconsistent pin table must fail the build, not the rig. + * ------------------------------------------------------------------------ */ + +/* Pin distinctness within each function group */ +#if BOARD_PIN_MAIN_PUMP == BOARD_PIN_RESERVOIR_PUMP +#error "Board sanity: BOARD_PIN_MAIN_PUMP and BOARD_PIN_RESERVOIR_PUMP must differ" +#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 +#if BOARD_PIN_BTN_MANUAL == BOARD_PIN_BTN_CONFIG +#error "Board sanity: BOARD_PIN_BTN_MANUAL and BOARD_PIN_BTN_CONFIG must differ" +#endif + +/* 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_RESERVOIR_PUMP == BOARD_PIN_LEVEL_HIGH) +#error "Board sanity: pump pins collide with level sensor pins" +#endif + +/* Feature flag consistency: BOARD_HAS_RS485_DE == 1 iff the DE pin exists */ +#if BOARD_HAS_RS485_DE && !defined(BOARD_PIN_RS485_DE) +#error "Board sanity: BOARD_HAS_RS485_DE is 1 but BOARD_PIN_RS485_DE is not defined" +#endif +#if !BOARD_HAS_RS485_DE && defined(BOARD_PIN_RS485_DE) +#error "Board sanity: BOARD_PIN_RS485_DE is defined but BOARD_HAS_RS485_DE is 0" +#endif + #endif /* WATERINGSYSTEM_BOARD_BOARD_H */ diff --git a/firmware/components/interfaces/CMakeLists.txt b/firmware/components/interfaces/CMakeLists.txt new file mode 100644 index 0000000..b0d4720 --- /dev/null +++ b/firmware/components/interfaces/CMakeLists.txt @@ -0,0 +1,6 @@ +# interfaces — header-only component, no IDF dependencies. +# These headers must stay includable on the host (linux preview target) +# without pulling in any ESP-IDF or hardware headers. +idf_component_register( + INCLUDE_DIRS "include" +) diff --git a/firmware/components/interfaces/include/interfaces/IActuator.h b/firmware/components/interfaces/include/interfaces/IActuator.h new file mode 100644 index 0000000..981625b --- /dev/null +++ b/firmware/components/interfaces/include/interfaces/IActuator.h @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file IActuator.h + * @brief Base interface for all actuators in the WateringSystem. + * + * Ported from the frozen Arduino firmware (include/actuators/IActuator.h) + * with Arduino types removed. This header is part of the header-only + * `interfaces` component and MUST NOT include any IDF or hardware headers + * (it is compiled on the host in the linux-target test suite). + */ + +#ifndef WATERINGSYSTEM_INTERFACES_IACTUATOR_H +#define WATERINGSYSTEM_INTERFACES_IACTUATOR_H + +#include + +/** + * @brief Base interface for all actuators. + * + * Defines the common functionality for all actuator types: initialization, + * availability and error reporting. + */ +class IActuator { +public: + virtual ~IActuator() = default; + + /** + * @brief Initialize the actuator. + * + * Idempotent. Implementations MUST force the actuator into its safe + * OFF state before any other action (boot fail-safe chain). + * + * @return true if initialization succeeded, false otherwise. + */ + virtual bool initialize() = 0; + + /** + * @brief Check if the actuator is available and working. + * @return true if available, false otherwise. + */ + virtual bool isAvailable() const = 0; + + /** + * @brief Get the name of the actuator (identity for logs/diagnostics). + * @return Actuator name. + */ + virtual const std::string& getName() const = 0; + + /** + * @brief Get the last error code. + * @return Error code, 0 if no error. + */ + virtual int getLastError() const = 0; +}; + +#endif /* WATERINGSYSTEM_INTERFACES_IACTUATOR_H */ diff --git a/firmware/components/interfaces/include/interfaces/ITimeProvider.h b/firmware/components/interfaces/include/interfaces/ITimeProvider.h new file mode 100644 index 0000000..625b4c8 --- /dev/null +++ b/firmware/components/interfaces/include/interfaces/ITimeProvider.h @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file ITimeProvider.h + * @brief Injected monotonic time source (milliseconds). + * + * Time is injected so that all timing/safety logic is deterministic under + * host tests (FakeTimeProvider) and never calls esp_timer directly — + * esp_timer is not simulated on the IDF linux preview target. + * + * Part of the header-only `interfaces` component: no IDF includes allowed. + */ + +#ifndef WATERINGSYSTEM_INTERFACES_ITIMEPROVIDER_H +#define WATERINGSYSTEM_INTERFACES_ITIMEPROVIDER_H + +#include + +/** + * @brief Monotonic millisecond clock. + */ +class ITimeProvider { +public: + virtual ~ITimeProvider() = default; + + /** + * @brief Current monotonic time in milliseconds. + * + * Never decreases. int64_t milliseconds cannot wrap within the device + * lifetime. + */ + virtual int64_t nowMs() = 0; +}; + +#endif /* WATERINGSYSTEM_INTERFACES_ITIMEPROVIDER_H */ diff --git a/firmware/components/interfaces/include/interfaces/IWaterPump.h b/firmware/components/interfaces/include/interfaces/IWaterPump.h new file mode 100644 index 0000000..b90bf4d --- /dev/null +++ b/firmware/components/interfaces/include/interfaces/IWaterPump.h @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file IWaterPump.h + * @brief Interface for water pump control in the WateringSystem. + * + * Ported from the frozen Arduino firmware (include/actuators/IWaterPump.h) + * with a tightened contract (see specs/002-pump-gpio-board/contracts/ + * iwaterpump.md): no indefinite runs, no silent clamping, no restart of a + * running pump's clock, observable stop reasons. + * + * Part of the header-only `interfaces` component: no IDF includes allowed. + */ + +#ifndef WATERINGSYSTEM_INTERFACES_IWATERPUMP_H +#define WATERINGSYSTEM_INTERFACES_IWATERPUMP_H + +#include + +#include "interfaces/IActuator.h" + +/** + * @brief Why a pump last transitioned to the stopped state. + */ +enum class StopReason { + None, ///< Never stopped since boot (no run completed yet) + Commanded, ///< Explicit stop() call + DurationElapsed, ///< Timed run completed normally + MaxRuntimeForced, ///< Hard max-runtime cap enforced (logged as an error) +}; + +/** + * @brief Water pump control: timed runs with hard max-runtime enforcement. + */ +class IWaterPump : public IActuator { +public: + /** + * @brief Start a timed run. + * + * Contract: + * - durationS <= 0 -> rejected (false): no indefinite runs + * - durationS > max runtime -> rejected (false): no silent clamping + * - already running -> rejected (false): clock NOT restarted + * - success -> output ON exactly once, true returned + * + * Rejected calls cause no output change and no state change. + * + * @param durationS Requested run duration in seconds. + * @return true if the run was started, false if rejected. + */ + virtual bool runFor(int durationS) = 0; + + /** + * @brief Stop the pump. + * + * Always allowed; stopping a stopped pump is a successful no-op. + * + * @return true on success (including the no-op case). + */ + virtual bool stop() = 0; + + /** + * @brief Check whether the pump is currently running. + */ + virtual bool isRunning() const = 0; + + /** + * @brief Periodic enforcement; call at main-loop cadence (>= 10 Hz). + * + * Stops the pump when the requested duration elapses or when the hard + * max runtime (300 s) is reached. A max-runtime stop is logged at + * warning/error level and observable via getLastStopReason(). + */ + virtual void update() = 0; + + /** + * @brief Elapsed time of the current run in milliseconds; 0 when stopped. + */ + virtual int64_t getCurrentRunTimeMs() const = 0; + + /** + * @brief Total accumulated run time since boot in milliseconds. + */ + virtual int64_t getAccumulatedRunTimeMs() const = 0; + + /** + * @brief Reason for the most recent stop (StopReason::None if never run). + */ + virtual StopReason getLastStopReason() const = 0; +}; + +#endif /* WATERINGSYSTEM_INTERFACES_IWATERPUMP_H */ diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 1a0f1d6..5c53f49 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -1,4 +1,5 @@ idf_component_register( - SRCS "app_main.cpp" + SRCS "app_main.cpp" "diag_console.cpp" PRIV_REQUIRES board esp_driver_gpio esp_app_format + actuators interfaces console esp_timer ) diff --git a/firmware/main/app_main.cpp b/firmware/main/app_main.cpp index 8473f95..666f785 100644 --- a/firmware/main/app_main.cpp +++ b/firmware/main/app_main.cpp @@ -1,6 +1,6 @@ /** * @file app_main.cpp - * @brief WateringSystem firmware entry point (phase 0 skeleton). + * @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 @@ -23,6 +23,14 @@ #include "esp_idf_version.h" #include "esp_log.h" #include "driver/gpio.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "actuators/EspTimeProvider.h" +#include "actuators/GpioWaterPump.h" +#include "actuators/LockedWaterPump.h" + +#include "diag_console.h" static const char *TAG = "app_main"; @@ -81,4 +89,48 @@ extern "C" void app_main(void) ESP_LOGI(TAG, "ESP-IDF: %s", esp_get_idf_version()); 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. + static EspTimeProvider time_provider; + static GpioWaterPump plant_pump( + static_cast(BOARD_PIN_MAIN_PUMP), "plant", + time_provider); + 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); + + // 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()) { + ESP_LOGE(TAG, "FATAL: pump driver initialization failed"); + abort(); + } + + // Serial diagnostic REPL (rig testing; contracts/serial-diagnostic.md). + diag_console_register_pumps(plant, reservoir); + esp_err_t err = diag_console_start(); + if (err != ESP_OK) { + // Console is a diagnostic aid, not a safety function: log and keep + // running so the pump safety loop below still executes. + ESP_LOGE(TAG, "diag console failed to start: %s", + esp_err_to_name(err)); + } + + // Main loop: poll pump enforcement at 10 Hz. update() applies the timed + // self-stop and the hard 300 s max-runtime cap. + while (true) { + plant.update(); + reservoir.update(); + vTaskDelay(pdMS_TO_TICKS(100)); + } } diff --git a/firmware/main/diag_console.cpp b/firmware/main/diag_console.cpp new file mode 100644 index 0000000..42a55ef --- /dev/null +++ b/firmware/main/diag_console.cpp @@ -0,0 +1,199 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file diag_console.cpp + * @brief Serial diagnostic REPL implementation. + * + * Command grammar and response formats are normative, defined in + * specs/002-pump-gpio-board/contracts/serial-diagnostic.md: + * + * pump start # timed run; 1..300 + * pump stop + * pump status + * pump status # both pumps + * + * 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 + * constructors (they would run before the boot pump fail-safe). + */ + +#include "diag_console.h" + +#include +#include +#include + +#include "esp_console.h" + +#include "interfaces/IWaterPump.h" + +namespace { + +/// Console-side bookkeeping per pump (interface has no duration getter; +/// the requested duration is recorded here on every accepted start). +struct PumpSlot { + const char *name; + IWaterPump *pump; + int lastStartedDurationS; +}; + +// Trivially initialized — safe before app_main (no static constructors). +PumpSlot s_slots[2] = { + {"plant", nullptr, 0}, + {"reservoir", nullptr, 0}, +}; + +const char *stop_reason_str(StopReason reason) +{ + switch (reason) { + case StopReason::Commanded: + return "commanded"; + case StopReason::DurationElapsed: + return "duration_elapsed"; + case StopReason::MaxRuntimeForced: + return "max_runtime_forced"; + case StopReason::None: + default: + return "none"; + } +} + +void print_status(const PumpSlot &slot) +{ + if (slot.pump->isRunning()) { + printf("%s: running %.1f/%.1f s\n", slot.name, + static_cast(slot.pump->getCurrentRunTimeMs()) / 1000.0, + static_cast(slot.lastStartedDurationS)); + } else { + printf("%s: stopped, last stop=%s, total runtime %.1f s\n", slot.name, + stop_reason_str(slot.pump->getLastStopReason()), + static_cast(slot.pump->getAccumulatedRunTimeMs()) / + 1000.0); + } +} + +int print_usage(void) +{ + printf("ERR usage: pump |stop|status> " + "| pump status\n"); + return 1; +} + +int cmd_start(PumpSlot &slot, const char *secondsArg) +{ + char *end = nullptr; + const long seconds = strtol(secondsArg, &end, 10); + if (end == secondsArg || *end != '\0' || seconds < 1 || seconds > 300) { + printf("ERR duration must be 1..300 s\n"); + return 1; + } + if (slot.pump->isRunning()) { + printf("ERR %s already running (%lld s elapsed)\n", slot.name, + static_cast(slot.pump->getCurrentRunTimeMs() / + 1000)); + return 1; + } + if (!slot.pump->runFor(static_cast(seconds))) { + // Range and running state are pre-checked above; reaching this + // means a driver-level failure. + printf("ERR %s failed to start\n", slot.name); + return 1; + } + slot.lastStartedDurationS = static_cast(seconds); + printf("OK %s running for %d s\n", slot.name, static_cast(seconds)); + return 0; +} + +int cmd_stop(PumpSlot &slot) +{ + if (!slot.pump->isRunning()) { + printf("OK %s already stopped\n", slot.name); + return 0; + } + const int64_t ranMs = slot.pump->getCurrentRunTimeMs(); + slot.pump->stop(); + printf("OK %s stopped (reason=commanded, ran %.1f s)\n", slot.name, + static_cast(ranMs) / 1000.0); + return 0; +} + +int pump_cmd(int argc, char **argv) +{ + // "pump status" — both pumps. + if (argc == 2 && strcmp(argv[1], "status") == 0) { + for (PumpSlot &slot : s_slots) { + if (slot.pump != nullptr) { + print_status(slot); + } + } + return 0; + } + if (argc < 3) { + return print_usage(); + } + + PumpSlot *slot = nullptr; + for (PumpSlot &candidate : s_slots) { + if (strcmp(argv[1], candidate.name) == 0) { + slot = &candidate; + break; + } + } + if (slot == nullptr || slot->pump == nullptr) { + return print_usage(); + } + + if (strcmp(argv[2], "start") == 0 && argc == 4) { + return cmd_start(*slot, argv[3]); + } + if (strcmp(argv[2], "stop") == 0 && argc == 3) { + return cmd_stop(*slot); + } + if (strcmp(argv[2], "status") == 0 && argc == 3) { + print_status(*slot); + return 0; + } + return print_usage(); +} + +} // namespace + +void diag_console_register_pumps(IWaterPump& plant, IWaterPump& reservoir) +{ + s_slots[0].pump = &plant; + s_slots[1].pump = &reservoir; +} + +esp_err_t diag_console_start(void) +{ + esp_console_repl_t *repl = nullptr; + esp_console_repl_config_t repl_config = ESP_CONSOLE_REPL_CONFIG_DEFAULT(); + repl_config.prompt = "ws>"; + + esp_console_dev_uart_config_t uart_config = + ESP_CONSOLE_DEV_UART_CONFIG_DEFAULT(); + + esp_err_t err = + esp_console_new_repl_uart(&uart_config, &repl_config, &repl); + if (err != ESP_OK) { + return err; + } + + const esp_console_cmd_t cmd = { + .command = "pump", + .help = "pump |stop|status> " + "| pump status", + .hint = nullptr, + .func = &pump_cmd, + .argtable = nullptr, + .func_w_context = nullptr, + .context = nullptr, + }; + err = esp_console_cmd_register(&cmd); + 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 new file mode 100644 index 0000000..2505d27 --- /dev/null +++ b/firmware/main/diag_console.h @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file diag_console.h + * @brief Serial diagnostic REPL (esp_console) for rig testing. + * + * Temporary scope per specs/002-pump-gpio-board/contracts/ + * serial-diagnostic.md — full FR12 diagnostics arrive in later phases. + */ + +#ifndef WATERINGSYSTEM_MAIN_DIAG_CONSOLE_H +#define WATERINGSYSTEM_MAIN_DIAG_CONSOLE_H + +#include "esp_err.h" +#include "interfaces/IWaterPump.h" + +/** + * @brief Register the pump instances the console commands operate on. + * + * Must be called before diag_console_start(). Plain pointer registration — + * no static constructors involved (boot fail-safe rule). + */ +void diag_console_register_pumps(IWaterPump& plant, IWaterPump& reservoir); + +/** + * @brief Start the UART REPL (prompt "ws>") and register the pump command. + * + * @return ESP_OK on success, the failing esp_err_t otherwise. + */ +esp_err_t diag_console_start(void); + +#endif /* WATERINGSYSTEM_MAIN_DIAG_CONSOLE_H */ diff --git a/firmware/test_apps/host/CMakeLists.txt b/firmware/test_apps/host/CMakeLists.txt new file mode 100644 index 0000000..8b768c1 --- /dev/null +++ b/firmware/test_apps/host/CMakeLists.txt @@ -0,0 +1,16 @@ +# Host test app for the pump actuator layer (IDF linux preview target). +# +# Built and run with no ESP32 attached: +# idf.py --preview set-target linux && idf.py build && ./build/pump_host_tests.elf +# The executable's exit code equals the Unity failure count (CI gate). +cmake_minimum_required(VERSION 3.22) + +# Reuse the firmware components (interfaces, actuators) without copying. +set(EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../../components") + +# Component isolation: only main + its requirements are built, so the +# esp32 firmware sdkconfig is never polluted by the linux target. +set(COMPONENTS main) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(pump_host_tests) diff --git a/firmware/test_apps/host/main/CMakeLists.txt b/firmware/test_apps/host/main/CMakeLists.txt new file mode 100644 index 0000000..4a6fa99 --- /dev/null +++ b/firmware/test_apps/host/main/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRCS "test_water_pump.cpp" + REQUIRES unity actuators interfaces +) diff --git a/firmware/test_apps/host/main/test_water_pump.cpp b/firmware/test_apps/host/main/test_water_pump.cpp new file mode 100644 index 0000000..ddf5bd9 --- /dev/null +++ b/firmware/test_apps/host/main/test_water_pump.cpp @@ -0,0 +1,266 @@ +// SPDX-FileCopyrightText: 2026 Cryptotomte +// SPDX-License-Identifier: AGPL-3.0-or-later +/** + * @file test_water_pump.cpp + * @brief Host tests for the WaterPump state machine (linux preview target). + * + * Tests the REAL enforcement logic (WaterPump base class) via + * MockWaterPump (records applyOutput transitions) and FakeTimeProvider + * (manual clock). Plain Unity runner: the process exit code equals the + * failure count and is the CI gate. + * + * Coverage maps to the invariants in + * specs/002-pump-gpio-board/contracts/iwaterpump.md. + */ + +#include +#include + +#include "unity.h" + +#include "actuators/LockedWaterPump.h" +#include "actuators/testing/FakeTimeProvider.h" +#include "actuators/testing/MockWaterPump.h" + +// Unity requires setUp/tearDown definitions. +extern "C" void setUp(void) {} +extern "C" void tearDown(void) {} + +namespace { + +constexpr int64_t kMaxRunTimeMs = WaterPump::kDefaultMaxRunTimeMs; // 300 000 + +/// Fresh pump + clock per test; initialize() is part of the fixture. +struct Fixture { + FakeTimeProvider clock; + MockWaterPump pump{"plant", clock}; + + Fixture() { TEST_ASSERT_TRUE(pump.initialize()); } +}; + +} // namespace + +// -------------------------------------------------------------------------- +// Duration self-stop at the exact boundary (SC-003) +// -------------------------------------------------------------------------- +static void test_duration_self_stop_at_exact_boundary(void) +{ + Fixture f; + TEST_ASSERT_TRUE(f.pump.runFor(10)); + TEST_ASSERT_TRUE(f.pump.isRunning()); + + // One millisecond before the boundary: still running. + f.clock.advance(9999); + f.pump.update(); + TEST_ASSERT_TRUE(f.pump.isRunning()); + TEST_ASSERT_EQUAL_INT64(9999, f.pump.getCurrentRunTimeMs()); + + // Exactly at the boundary: stopped with DurationElapsed. + f.clock.advance(1); + f.pump.update(); + TEST_ASSERT_FALSE(f.pump.isRunning()); + TEST_ASSERT_EQUAL(static_cast(StopReason::DurationElapsed), + static_cast(f.pump.getLastStopReason())); + TEST_ASSERT_EQUAL_INT64(0, f.pump.getCurrentRunTimeMs()); +} + +// -------------------------------------------------------------------------- +// Max-runtime forced stop (invariant 3, HIL item 4) +// -------------------------------------------------------------------------- +static void test_max_runtime_forced_stop(void) +{ + Fixture f; + // 300 s is the maximum accepted duration; at the 300 s boundary the + // max-runtime cap wins and the stop is reported as forced. + TEST_ASSERT_TRUE(f.pump.runFor(300)); + + f.clock.advance(kMaxRunTimeMs - 1); + f.pump.update(); + TEST_ASSERT_TRUE(f.pump.isRunning()); + + f.clock.advance(1); + f.pump.update(); + TEST_ASSERT_FALSE(f.pump.isRunning()); + TEST_ASSERT_EQUAL(static_cast(StopReason::MaxRuntimeForced), + static_cast(f.pump.getLastStopReason())); +} + +// -------------------------------------------------------------------------- +// Rejected starts cause no output change and no state change (invariant 4) +// -------------------------------------------------------------------------- +static void test_reject_zero_duration(void) +{ + Fixture f; + const size_t callsBefore = f.pump.outputCalls.size(); + + TEST_ASSERT_FALSE(f.pump.runFor(0)); + + TEST_ASSERT_FALSE(f.pump.isRunning()); + TEST_ASSERT_EQUAL(callsBefore, f.pump.outputCalls.size()); + TEST_ASSERT_EQUAL(static_cast(StopReason::None), + static_cast(f.pump.getLastStopReason())); +} + +static void test_reject_duration_over_max(void) +{ + Fixture f; + const size_t callsBefore = f.pump.outputCalls.size(); + + TEST_ASSERT_FALSE(f.pump.runFor(301)); // no silent clamping + + TEST_ASSERT_FALSE(f.pump.isRunning()); + TEST_ASSERT_EQUAL(callsBefore, f.pump.outputCalls.size()); +} + +static void test_reject_start_while_running(void) +{ + Fixture f; + TEST_ASSERT_TRUE(f.pump.runFor(10)); + const size_t callsBefore = f.pump.outputCalls.size(); + + f.clock.advance(4000); + TEST_ASSERT_FALSE(f.pump.runFor(5)); + + // No output change, and the running clock was NOT restarted. + TEST_ASSERT_EQUAL(callsBefore, f.pump.outputCalls.size()); + TEST_ASSERT_TRUE(f.pump.isRunning()); + TEST_ASSERT_EQUAL_INT64(4000, f.pump.getCurrentRunTimeMs()); + + // The original schedule still applies: self-stop at 10 s, not 4+5 s. + f.clock.advance(6000); + f.pump.update(); + TEST_ASSERT_FALSE(f.pump.isRunning()); + TEST_ASSERT_EQUAL(static_cast(StopReason::DurationElapsed), + static_cast(f.pump.getLastStopReason())); +} + +// -------------------------------------------------------------------------- +// Stop when stopped is a successful no-op +// -------------------------------------------------------------------------- +static void test_stop_when_stopped_noop(void) +{ + Fixture f; + const size_t callsBefore = f.pump.outputCalls.size(); + + TEST_ASSERT_TRUE(f.pump.stop()); + + TEST_ASSERT_FALSE(f.pump.isRunning()); + TEST_ASSERT_EQUAL(callsBefore, f.pump.outputCalls.size()); + TEST_ASSERT_EQUAL(static_cast(StopReason::None), + static_cast(f.pump.getLastStopReason())); +} + +// -------------------------------------------------------------------------- +// Paired output transitions across a full cycle (invariant 1 + 2) +// -------------------------------------------------------------------------- +static void test_paired_output_transitions_full_cycle(void) +{ + Fixture f; + TEST_ASSERT_TRUE(f.pump.runFor(10)); + f.clock.advance(3000); + TEST_ASSERT_TRUE(f.pump.stop()); + TEST_ASSERT_EQUAL(static_cast(StopReason::Commanded), + static_cast(f.pump.getLastStopReason())); + + // initialize() OFF first, then exactly one ON and exactly one OFF. + const std::vector expected = {false, true, false}; + TEST_ASSERT_TRUE(f.pump.outputCalls == expected); +} + +// -------------------------------------------------------------------------- +// Accumulated runtime across two runs +// -------------------------------------------------------------------------- +static void test_accumulated_runtime_across_runs(void) +{ + Fixture f; + + // Run 1: full 10 s timed run (self-stop). + TEST_ASSERT_TRUE(f.pump.runFor(10)); + f.clock.advance(10'000); + f.pump.update(); + TEST_ASSERT_FALSE(f.pump.isRunning()); + TEST_ASSERT_EQUAL_INT64(10'000, f.pump.getAccumulatedRunTimeMs()); + + // Run 2: 3 s of a 5 s run, then commanded stop. + f.clock.advance(60'000); // idle time must not count + TEST_ASSERT_TRUE(f.pump.runFor(5)); + f.clock.advance(3000); + TEST_ASSERT_TRUE(f.pump.stop()); + + TEST_ASSERT_EQUAL_INT64(13'000, f.pump.getAccumulatedRunTimeMs()); +} + +// -------------------------------------------------------------------------- +// Enforcement happens within a single update() poll, however late it comes +// -------------------------------------------------------------------------- +static void test_enforcement_within_one_poll(void) +{ + Fixture f; + TEST_ASSERT_TRUE(f.pump.runFor(300)); + + // Polling stalls far past the cap; the very next update() must stop the + // pump (forced) — no second poll needed. + f.clock.advance(kMaxRunTimeMs + 100'000); + f.pump.update(); + + TEST_ASSERT_FALSE(f.pump.isRunning()); + TEST_ASSERT_EQUAL(static_cast(StopReason::MaxRuntimeForced), + static_cast(f.pump.getLastStopReason())); + + // Output ended OFF with exactly paired transitions. + const std::vector expected = {false, true, false}; + TEST_ASSERT_TRUE(f.pump.outputCalls == expected); +} + +// -------------------------------------------------------------------------- +// LockedWaterPump decorator delegates the full contract path unchanged +// (the wrapper adds task-level mutex serialization; see LockedWaterPump.h) +// -------------------------------------------------------------------------- +static void test_locked_wrapper_delegates_full_cycle(void) +{ + FakeTimeProvider clock; + MockWaterPump inner("plant", clock); + LockedWaterPump pump(inner); + + TEST_ASSERT_TRUE(pump.initialize()); + TEST_ASSERT_TRUE(pump.isAvailable()); + TEST_ASSERT_EQUAL_STRING("plant", pump.getName().c_str()); + TEST_ASSERT_EQUAL(0, pump.getLastError()); + + // Contract checks behave identically through the wrapper. + TEST_ASSERT_FALSE(pump.runFor(0)); + TEST_ASSERT_TRUE(pump.runFor(10)); + TEST_ASSERT_FALSE(pump.runFor(5)); // already running -> rejected + TEST_ASSERT_TRUE(pump.isRunning()); + + clock.advance(4000); + pump.update(); + TEST_ASSERT_TRUE(pump.isRunning()); + TEST_ASSERT_EQUAL_INT64(4000, pump.getCurrentRunTimeMs()); + + TEST_ASSERT_TRUE(pump.stop()); + TEST_ASSERT_FALSE(pump.isRunning()); + TEST_ASSERT_EQUAL(static_cast(StopReason::Commanded), + static_cast(pump.getLastStopReason())); + TEST_ASSERT_EQUAL_INT64(4000, pump.getAccumulatedRunTimeMs()); + + // Output transitions reached the wrapped pump exactly paired. + const std::vector expected = {false, true, false}; + TEST_ASSERT_TRUE(inner.outputCalls == expected); +} + +extern "C" void app_main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_duration_self_stop_at_exact_boundary); + RUN_TEST(test_max_runtime_forced_stop); + RUN_TEST(test_reject_zero_duration); + RUN_TEST(test_reject_duration_over_max); + RUN_TEST(test_reject_start_while_running); + RUN_TEST(test_stop_when_stopped_noop); + RUN_TEST(test_paired_output_transitions_full_cycle); + RUN_TEST(test_accumulated_runtime_across_runs); + RUN_TEST(test_enforcement_within_one_poll); + RUN_TEST(test_locked_wrapper_delegates_full_cycle); + std::exit(UNITY_END()); +} diff --git a/firmware/test_apps/host/sdkconfig.defaults b/firmware/test_apps/host/sdkconfig.defaults new file mode 100644 index 0000000..4e40885 --- /dev/null +++ b/firmware/test_apps/host/sdkconfig.defaults @@ -0,0 +1 @@ +# Host test app (linux preview target) — IDF defaults are sufficient. diff --git a/specs/002-pump-gpio-board/checklists/hil.md b/specs/002-pump-gpio-board/checklists/hil.md new file mode 100644 index 0000000..172916d --- /dev/null +++ b/specs/002-pump-gpio-board/checklists/hil.md @@ -0,0 +1,88 @@ +# HIL Test Checklist: Pump Actuator Layer (CP3 — bench rig) + +**Feature**: 002-pump-gpio-board | **Runner**: Paul | **Board**: rev1 devkit rig + +**Setup**: Flash the rev1 build, scope/meter on GPIO 26 (plant) and GPIO 27 +(reservoir), serial terminal at 115200 baud on UART0. Commands and exact +response formats per [contracts/serial-diagnostic.md](../contracts/serial-diagnostic.md). + +## Flashing from the CI artifact (macOS, no ESP-IDF needed) + +> ⚠️ Flash the **bench-rig devkit only** — never the greenhouse unit (it keeps +> running the Arduino firmware untouched). + +1. **Get the binaries** — from the PR's green `firmware-build` run, download the + artifact named **`wateringsystem-rev1_devkit`** (Actions → run → Artifacts), + or from a terminal: + ```bash + gh run download --repo cryptotomte/WateringSystem -n wateringsystem-rev1_devkit -D ~/Downloads/ws-rev1 + ``` + The board name in the artifact IS the proof you took the right build — and + step 4's boot banner double-checks it. +2. **Install esptool once** (small standalone flasher, no ESP-IDF): + ```bash + brew install esptool + ``` +3. **Connect the devkit (USB) and flash** — find the port with + `ls /dev/cu.usbserial* /dev/cu.SLAB*`, then (adjust paths/port): + ```bash + cd ~/Downloads/ws-rev1 + esptool --chip esp32 --port /dev/cu.usbserial-0001 erase-flash # wipes whatever ran before + esptool --chip esp32 --port /dev/cu.usbserial-0001 write-flash \ + 0x1000 bootloader/bootloader.bin \ + 0x8000 partition_table/partition-table.bin \ + 0xd000 ota_data_initial.bin \ + 0x10000 wateringsystem.bin + ``` + (Older esptool versions spell the commands `erase_flash`/`write_flash`.) +4. **Open the serial monitor and verify** — `screen /dev/cu.usbserial-0001 115200` + (exit: `Ctrl-A` then `K`, confirm `y`). Press the EN/reset button. The boot + banner must show **`Board: rev1_devkit`** and version `3.0.0-dev` — that is + the "am I running the right firmware?" check. Then the `ws>` prompt appears; + `help` lists the commands. + +## Checklist + +- [ ] **HIL-1: Boot fail-safe** — Power-cycle the rig and watch GPIO 26/27 from + power-on. Expected: neither pump output goes high at any point before a + command is issued; boot log shows `Pumps forced OFF (fail-safe boot state)` + and the `ws>` prompt appears. + +- [ ] **HIL-2: Timed run with self-stop** — `pump plant start 10`. Expected: + response `OK plant running for 10 s`; GPIO 26 goes high immediately, then + returns low after 10 s (±1 s) with no further command; + `pump plant status` afterwards shows `last stop=duration_elapsed`. + +- [ ] **HIL-3: Manual stop** — `pump plant start 10`, then within a few seconds + `pump plant stop`. Expected: GPIO 26 drops low immediately on the stop + command; response `OK plant stopped (reason=commanded, ran s)` with a + plausible elapsed time. + +- [ ] **HIL-4: Max-runtime forced stop** — `pump reservoir start 300`. Expected: + GPIO 27 high for 300 s, then forced low; an error/warning is logged on the + console at the 300 s mark; `pump reservoir status` shows + `last stop=max_runtime_forced`. + +- [ ] **HIL-5: Rejected durations** — `pump plant start 0` and + `pump plant start 301`. Expected: both rejected with + `ERR duration must be 1..300 s`; GPIO 26 never moves; `pump plant status` + unchanged by the rejected commands. + +- [ ] **HIL-6: Hard reset mid-run** — `pump plant start 60`, then press the + EN/reset button mid-run. Expected: GPIO 26 is low immediately from reboot + and STAYS low (the interrupted run does not resume); boot log again shows + the fail-safe message and the `ws>` prompt returns. + +## Sign-off + +- [ ] All six items pass — record date, firmware version (`version.txt` / + boot banner) and any observations below. + +| Item | Pass/Fail | Notes | +|---|---|---| +| HIL-1 | | | +| HIL-2 | | | +| HIL-3 | | | +| HIL-4 | | | +| HIL-5 | | | +| HIL-6 | | | diff --git a/specs/002-pump-gpio-board/checklists/requirements.md b/specs/002-pump-gpio-board/checklists/requirements.md new file mode 100644 index 0000000..1a89a61 --- /dev/null +++ b/specs/002-pump-gpio-board/checklists/requirements.md @@ -0,0 +1,40 @@ +# Specification Quality Checklist: Pump Actuator Layer and Board Abstraction + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-10 +**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-inherent hardware facts (pin numbers, board revisions, 300 s cap) are + requirements in an embedded project, not implementation details — they define + the contract with the physical rig and the parity checklist. +- The one genuinely open semantic question (indefinite runs / duration 0) is + resolved in Assumptions as a deliberate behavior change already decided in + PR-02/parity checklist §4 — no [NEEDS CLARIFICATION] needed. +- Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan` diff --git a/specs/002-pump-gpio-board/contracts/iwaterpump.md b/specs/002-pump-gpio-board/contracts/iwaterpump.md new file mode 100644 index 0000000..0564ec8 --- /dev/null +++ b/specs/002-pump-gpio-board/contracts/iwaterpump.md @@ -0,0 +1,69 @@ +# Contract: IActuator / IWaterPump / ITimeProvider + +**Feature**: 002-pump-gpio-board — C++ interface contract (header-only component +`firmware/components/interfaces/`, no IDF includes allowed in these headers). + +Ported from the Arduino interfaces (`include/actuators/IActuator.h`, +`IWaterPump.h`) with Arduino types removed. Semantics below are normative; exact +signatures may be refined during implementation but MUST keep these guarantees. + +## ITimeProvider + +```cpp +class ITimeProvider { +public: + virtual ~ITimeProvider() = default; + virtual int64_t nowMs() = 0; // monotonic milliseconds; never decreases +}; +``` + +## IActuator + +```cpp +class IActuator { +public: + virtual ~IActuator() = default; + virtual bool initialize() = 0; // idempotent; forces safe OFF state + virtual bool isAvailable() const = 0; + virtual const std::string& getName() const = 0; + virtual int getLastError() const = 0; +}; +``` + +## IWaterPump (extends IActuator) + +```cpp +class IWaterPump : public IActuator { +public: + // Start a timed run. Contract: + // - durationS <= 0 -> rejected (false): no indefinite runs + // - durationS > maxRunTimeS -> rejected (false): no silent clamping + // - already running -> rejected (false): clock NOT restarted + // - success -> output ON exactly once, true returned + virtual bool runFor(int durationS) = 0; + + // Stop. Always allowed; stopping a stopped pump is a successful no-op. + virtual bool stop() = 0; + + virtual bool isRunning() const = 0; + + // Periodic enforcement; call at main-loop cadence (>= 10 Hz recommended). + // Stops the pump when duration elapses or max runtime (300 s) is reached. + // Max-runtime stop is logged at warning/error level and observable via + // getLastStopReason(). + virtual void update() = 0; + + // Statistics / status (for diagnostics now, API later): + virtual int64_t getCurrentRunTimeMs() const = 0; // 0 when stopped + virtual int64_t getAccumulatedRunTimeMs() const = 0; + virtual StopReason getLastStopReason() const = 0; // None|Commanded|DurationElapsed|MaxRuntimeForced +}; +``` + +## Invariants (host-tested) + +1. Output transitions are exactly paired: every ON has exactly one OFF; no double-ON. +2. `initialize()` drives output OFF before any other action (boot fail-safe chain). +3. No code path keeps the output ON past `maxRunTime` by more than one update poll. +4. Rejected `runFor` calls cause no output change and no state change. +5. The interfaces component compiles with no IDF/Arduino headers (host-includable). diff --git a/specs/002-pump-gpio-board/contracts/serial-diagnostic.md b/specs/002-pump-gpio-board/contracts/serial-diagnostic.md new file mode 100644 index 0000000..dfe5cb2 --- /dev/null +++ b/specs/002-pump-gpio-board/contracts/serial-diagnostic.md @@ -0,0 +1,43 @@ +# Contract: Serial Diagnostic (esp_console REPL) + +**Feature**: 002-pump-gpio-board — rig-testing tool (HIL acceptance). Temporary +scope: full FR12 diagnostics arrive in later phases. + +## Transport + +UART0 console (same port as logs), `esp_console` REPL, prompt `ws>`. +`help` is provided by the console component automatically. + +## Command grammar + +``` +pump start # timed run; 1..300 +pump stop +pump status +pump status # both pumps +``` + +## Responses (line-oriented, human-readable, stable enough for HIL checklist) + +| Command outcome | Output (example) | +|---|---| +| start accepted | `OK plant running for 10 s` | +| start rejected — already running | `ERR plant already running (12 s elapsed)` | +| start rejected — bad duration | `ERR duration must be 1..300 s` | +| stop | `OK plant stopped (reason=commanded, ran 4.2 s)` | +| stop when stopped | `OK plant already stopped` | +| status | `plant: stopped, last stop=duration_elapsed, total runtime 34.5 s` / `reservoir: running 12.0/60.0 s` | + +Exit codes: console handler returns 0 on OK, 1 on ERR (esp_console convention). + +## HIL mapping (spec SC-005, acceptance scenarios US1) + +1. Boot rig → no pump output before any command (scope/meter GPIO 26/27). +2. `pump plant start 10` → pump on; self-stop after 10 s (±1 s). +3. `pump plant start 10` then `pump plant stop` → immediate stop. +4. `pump reservoir start 300` → forced stop at 300 s, `ERR`/warning logged, + `status` shows `last stop=max_runtime_forced`. +5. `pump plant start 0` and `pump plant start 301` → rejected with clear error. +6. `pump plant start 60`, then hard-reset the board mid-run (EN/reset button) → + pump output is off immediately from boot and stays off (boot fail-safe; spec + US1 scenario 1 / edge case "reset while running"). diff --git a/specs/002-pump-gpio-board/data-model.md b/specs/002-pump-gpio-board/data-model.md new file mode 100644 index 0000000..c02b641 --- /dev/null +++ b/specs/002-pump-gpio-board/data-model.md @@ -0,0 +1,79 @@ +# Data Model: Pump Actuator Layer and Board Abstraction + +**Feature**: 002-pump-gpio-board | **Date**: 2026-06-10 + +No persistent data in this feature (NVS config arrives in PR-06). The model is +in-memory state. + +## Entity: Pump (WaterPump) + +| Field | Type | Semantics | +|---|---|---| +| `name` | `std::string` | Identity for logs/diagnostics: `"plant"`, `"reservoir"` | +| `state` | enum `Stopped \| Running` | Output state mirror; never diverges from `applyOutput` history | +| `runStartedAtMs` | `int64_t` | Time (monotonic ms) the current run started; valid only in `Running` | +| `runDurationMs` | `int64_t` | Requested duration of the current timed run | +| `maxRunTimeMs` | `int64_t` (constexpr config, 300 000) | Hard cap; enforced for every run mode | +| `accumulatedRunTimeMs` | `int64_t` | Total run time since boot (statistics, status reporting) | +| `lastStopReason` | enum `None \| Commanded \| DurationElapsed \| MaxRuntimeForced` | Observability of why the pump stopped (FR-009) | + +### State machine + +``` + runFor(durationS) [0 < durationS·1000 ≤ maxRunTimeMs] (API in seconds, internal state in ms) + ┌─────────┐ ──────────────────────────────────────────► ┌─────────┐ + │ Stopped │ │ Running │ + └─────────┘ ◄────────────────────────────────────────── └─────────┘ + stop() → Commanded + update(): now-start ≥ duration → DurationElapsed + update(): now-start ≥ maxRunTime → MaxRuntimeForced (logged ERROR/WARN) +``` + +**Transition rules** (from spec FR-007..FR-009 + edge cases): + +- `runFor` with `durationS ≤ 0` → rejected (no indefinite runs — deliberate change). +- `runFor` with `durationS·1000 > maxRunTimeMs` → rejected with clear error (no silent clamping). +- `runFor` while `Running` → rejected; running clock NOT restarted/extended. +- `stop` while `Stopped` → success no-op. +- Every transition into `Running` calls `applyOutput(true)` exactly once; + every transition into `Stopped` calls `applyOutput(false)` exactly once. +- Construction/`init` → `applyOutput(false)` before anything else (boot fail-safe). +- Enforcement is evaluated in `update(nowMs)`; between polls the pump may overrun + by at most one poll interval (main loop ≥ 10 Hz ⇒ ≤ 100 ms, within SC-003's 1 s). + +## Entity: Board profile (compile-time) + +| Field | rev1_devkit | rev2 (provisional, TODO(SYNC1)) | +|---|---|---| +| `BOARD_NAME` | `"rev1_devkit"` | `"rev2"` | +| I2C SDA / SCL | 21 / 22 | 21 / 22 | +| RS485 TX / RX | 16 / 17 | 16 / 17 | +| RS485 DE | 25 (`BOARD_HAS_RS485_DE 1`) | — (flag 0, macro undefined) | +| Plant pump / Reservoir pump | 26 / 27 | 26 / 27 | +| Level low / high | 32 / 33 | 32 / 33 | +| `BOARD_LEVEL_SENSOR_ACTIVE_LOW` | 0 | 1 (2N7002 inverter) | +| `BOARD_HAS_INA226` | 0 | 1 | +| Status LED | 2 | 2 | +| Manual button / Config button | 5 / 18 | 5 / 18 | + +Exactly one profile active per build (Kconfig choice; `#error` otherwise). +Source of truth for rev1 values: `docs/parity-checklist.md` (NOT `docs/hardware.md`). + +## Entity: Time source + +| Implementation | Behavior | +|---|---| +| `ITimeProvider` | `int64_t nowMs()` — monotonic, never wraps in device lifetime (int64 ms) | +| `EspTimeProvider` (target) | `esp_timer_get_time() / 1000` | +| `FakeTimeProvider` (host) | starts at arbitrary epoch; `advance(ms)` under test control | + +## Relationships + +``` +app_main ──owns──► GpioWaterPump("plant", BOARD_PIN_PUMP_PLANT) ──is-a──► WaterPump ──implements──► IWaterPump + ──owns──► GpioWaterPump("reservoir", BOARD_PIN_PUMP_RESERVOIR) ─┘ + ──owns──► EspTimeProvider ──implements──► ITimeProvider (injected into both pumps) + ──polls──► pump.update() each loop iteration +diag_console ──uses──► IWaterPump& (both instances, by name) +host tests ──own──► MockWaterPump ──is-a──► WaterPump (logic under test) + FakeTimeProvider +``` diff --git a/specs/002-pump-gpio-board/plan.md b/specs/002-pump-gpio-board/plan.md new file mode 100644 index 0000000..c325cdd --- /dev/null +++ b/specs/002-pump-gpio-board/plan.md @@ -0,0 +1,119 @@ +# Implementation Plan: Pump Actuator Layer and Board Abstraction + +**Branch**: `002-pump-gpio-board` | **Date**: 2026-06-10 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `/specs/002-pump-gpio-board/spec.md` + +## Summary + +Port the pump actuator layer to native ESP-IDF and complete the board abstraction. +All timing/safety logic (timed runs, 300 s max-runtime enforcement, runtime +statistics) lives in a pure C++ `WaterPump` base class driven by an injected +monotonic time source and polled `update()`; the only hardware touchpoint is a +virtual `applyOutput(bool)` implemented by `GpioWaterPump` (active-HIGH MOSFET, +explicit OFF at init). Host tests on the IDF linux preview target (bundled Unity, +exit-code gate) exercise the real enforcement logic via `MockWaterPump` + +`FakeTimeProvider`. An `esp_console` REPL provides rig diagnostics. The `board` +component becomes the single source of truth for pins/feature flags on both boards. + +## Technical Context + +**Language/Version**: C++ (gnu++26 default of ESP-IDF v6.0.1; conservative ~C++23 feature use) + +**Primary Dependencies**: ESP-IDF v6.0.1 (pinned, Docker/CI) — components used: `esp_driver_gpio`, `console`, `esp_timer`, `unity` (host tests). No new managed components. + +**Storage**: N/A (runtime config arrives with NVS in PR-06; this PR uses constants) + +**Testing**: IDF-bundled Unity on the linux preview target (`firmware/test_apps/host/`, native executable, exit code = failure count) + CI matrix build of both boards + HIL checklist on the rev1 rig + +**Target Platform**: ESP32-WROOM-32E (rev1 devkit + rev2 custom PCB), host tests on linux (CI container) + +**Project Type**: Embedded firmware (ESP-IDF component architecture) + host test app + +**Performance Goals**: Timed-run self-stop within 1 s of configured duration (SC-003); `update()` polled at main-loop cadence (≥ 10 Hz ample) + +**Constraints**: Pumps off at boot/reset preserved (constitution I); no `esp_timer` calls in host-tested code (not simulated on linux target); no external dependencies (constitution III); frozen Arduino tree untouched (constitution IV) + +**Scale/Scope**: 2 pump instances; ~6 new/changed files in `firmware/components/`, 1 host test app, 1 CI job, board.h extension + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Gate | Status | +|---|---|---| +| I Safety First | Pumps off at boot unchanged (`app_main` fail-safe stays first); `GpioWaterPump` re-asserts OFF at init; max-runtime enforced in driver for every run mode; no indefinite runs | PASS | +| II Host-Testability | Enforcement logic in pure base class, tested on host via mock + fake clock; hardware only behind `applyOutput`/interfaces | PASS | +| III Reproducible Builds | No new managed components; Unity/console are IDF-bundled; CI builds both boards + runs host tests from clean checkout | PASS | +| IV Frozen Legacy | No changes under `src/`, `include/`, `data/`, `test/`, `platformio.ini` (Arduino code is read-only reference) | PASS | +| V Checkpoint Workflow | This plan → CP2; implementation via implementer agent; review → CP3 | PASS | +| VI English Outward | All deliverables in English | PASS | + +**Post-design re-check (after Phase 1)**: PASS — no violations introduced; Complexity Tracking empty. + +## Project Structure + +### Documentation (this feature) + +```text +specs/002-pump-gpio-board/ +├── spec.md +├── plan.md # This file +├── research.md # Phase 0 — 6 decisions (host tests, architecture, time, console, constants, mocks) +├── data-model.md # Phase 1 — pump state machine, entities +├── quickstart.md # Phase 1 — build/test/HIL validation guide +├── contracts/ +│ ├── iwaterpump.md # C++ interface contract + semantics +│ └── serial-diagnostic.md # REPL command grammar +├── checklists/requirements.md +└── tasks.md # Phase 2 (/speckit-tasks — not created by plan) +``` + +### Source Code (repository root) + +```text +firmware/ +├── components/ +│ ├── interfaces/ # NEW — header-only, no IDF deps +│ │ ├── CMakeLists.txt +│ │ └── include/interfaces/ +│ │ ├── IActuator.h # ported from Arduino include/actuators/ +│ │ ├── IWaterPump.h # ported, std::string, no Arduino types +│ │ └── ITimeProvider.h # NEW — monotonic ms clock +│ ├── actuators/ # NEW +│ │ ├── CMakeLists.txt # GpioWaterPump.cpp + esp_driver_gpio dep excluded on linux target +│ │ ├── include/actuators/ +│ │ │ ├── WaterPump.h # pure C++ base: all timing/safety logic +│ │ │ ├── GpioWaterPump.h # applyOutput → gpio_set_level (esp32 only) +│ │ │ ├── EspTimeProvider.h # esp_timer_get_time()/1000 (esp32 only) +│ │ │ └── testing/ +│ │ │ ├── MockWaterPump.h # header-only, host tests + PR-11 +│ │ │ └── FakeTimeProvider.h # header-only, manual advance +│ │ └── src/ +│ │ ├── WaterPump.cpp +│ │ └── GpioWaterPump.cpp +│ └── board/ # EXTENDED — LED 2, buttons 5/18, rev2 TODO(SYNC1) +│ └── include/board/board.h +├── main/ +│ ├── app_main.cpp # wire 2 pumps behind IWaterPump, poll update(), start REPL +│ ├── diag_console.cpp # NEW — esp_console: pump +│ └── CMakeLists.txt # + console dep +└── test_apps/ + └── host/ # NEW — IDF project, linux preview target + ├── CMakeLists.txt # set(COMPONENTS main) + ├── sdkconfig.defaults + └── main/ + ├── CMakeLists.txt # REQUIRES unity, actuators, interfaces + └── test_water_pump.cpp # Unity runner: UNITY_BEGIN/RUN_TEST/exit(UNITY_END()) + +.github/workflows/firmware-build.yml # + host-test job (set-target linux, build, run elf) +``` + +**Structure Decision**: Two new components keep interfaces (reused by PR-03..05, +PR-11) decoupled from actuator implementation; host tests are a separate IDF +project so the esp32 `sdkconfig` is never polluted by the linux target +(`set(COMPONENTS main)` isolation). Frozen Arduino tree untouched. + +## Complexity Tracking + +No constitution violations — table intentionally empty. diff --git a/specs/002-pump-gpio-board/quickstart.md b/specs/002-pump-gpio-board/quickstart.md new file mode 100644 index 0000000..f7d4b87 --- /dev/null +++ b/specs/002-pump-gpio-board/quickstart.md @@ -0,0 +1,49 @@ +# Quickstart: Pump Actuator Layer and Board Abstraction + +**Feature**: 002-pump-gpio-board — validation guide (no implementation details; +see [plan.md](plan.md) and [contracts/](contracts/)). + +## Prerequisites + +- Docker with the pinned image `espressif/idf:v6.0.1` (no local toolchain needed). +- Local note (macOS/OneDrive): copy the tree to `/tmp` before mounting in Docker — + the OneDrive path cannot be docker-mounted. +- HIL: rev1 devkit rig, scope/meter on GPIO 26/27, serial terminal 115200 baud. + +## 1. Build both boards (CI does the same) + +```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 +# fullclean or fresh copy, then: +docker run --rm -v "$PWD":/project -w /project espressif/idf:v6.0.1 \ + idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.board.rev2" build +``` + +Expected: both green; boot banner reports the right board name. + +## 2. Host tests (the new CI gate) + +```bash +cd firmware/test_apps/host +docker run --rm -v "$PWD":/project -w /project espressif/idf:v6.0.1 bash -c \ + "idf.py --preview set-target linux && idf.py build && ./build/*.elf" +echo $? # 0 = all tests pass; >0 = number of failures +``` + +Expected coverage: max-runtime enforcement, duration self-stop, rejected starts +(0, >300, already-running), paired output transitions, runtime statistics — all on +`FakeTimeProvider` (deterministic, no sleeps). + +## 3. HIL on the rig (CP3 checklist) + +Flash rev1 build, open serial terminal, then follow +[contracts/serial-diagnostic.md](contracts/serial-diagnostic.md) §HIL mapping: +boot-off check, timed self-stop, manual stop, 300 s forced stop, rejection cases. + +## 4. CI + +`firmware-build.yml` gains a `host-test` job; the build matrix is unchanged. +A PR is green when: both board builds pass + board-config grep passes + host test +executable exits 0. diff --git a/specs/002-pump-gpio-board/research.md b/specs/002-pump-gpio-board/research.md new file mode 100644 index 0000000..811af02 --- /dev/null +++ b/specs/002-pump-gpio-board/research.md @@ -0,0 +1,95 @@ +# Research: Pump Actuator Layer and Board Abstraction + +**Feature**: 002-pump-gpio-board | **Date**: 2026-06-10 + +All decisions verified empirically inside the pinned `espressif/idf:v6.0.1` Docker +image where noted. + +## D1. Host-test mechanism + +- **Decision**: Separate IDF project `firmware/test_apps/host/` using the IDF + **linux preview target** (`idf.py --preview set-target linux`) with the + IDF-bundled **Unity** framework in plain runner style: + `UNITY_BEGIN()` / `RUN_TEST()` / `std::exit(UNITY_END())` — exit code equals the + failure count, directly usable as CI gate. Build output is a native executable + (`build/.elf`) that runs inside the same CI container. +- **Rationale**: Zero external packages and zero vendored code (constitution III); + uses only IDF-bundled tooling; the pinned IDF version freezes any + "experimental target" instability; keeps a path open for testing code that uses + IDF APIs (log, NVS, esp_event are simulated on linux). Verified end-to-end in the + v6.0.1 image: passing run → exit 0, failing assertion → exit 1. +- **Alternatives considered**: (a) Plain CMake host build vendoring Unity's three + source files — simpler build, but vendors code unnecessarily; kept as documented + fallback (~1 h migration) if a future IDF bump breaks the preview target. + (b) pytest-embedded + `unity_run_menu()` — interactive/stdin-driven, adds Python + test deps; rejected. +- **Caveat noted**: `esp_timer` is NOT simulated on the linux target in v6.0.1 + (mock-only, needs Ruby/CMock). Consequence: code under host test must never call + `esp_timer` directly → time is injected (D3). +- **CI**: one extra job in `firmware-build.yml` via `espressif/esp-idf-ci-action` + with `command: idf.py --preview set-target linux && idf.py build && ./build/.elf`. + +## D2. Actuator architecture — template method, logic in the base + +- **Decision**: `WaterPump` (pure C++, implements `IWaterPump`) contains ALL timing + and safety logic: timed-run state, max-runtime enforcement, runtime statistics, + driven by polled `update()` and an injected time source. One pure virtual + `applyOutput(bool on)` is the only hardware touchpoint. `GpioWaterPump` overrides + it with `gpio_set_level` (active HIGH); `MockWaterPump` (host tests) overrides it + with state recording. Interfaces live in a dedicated header-only component + `firmware/components/interfaces/` (`IActuator.h`, `IWaterPump.h`, + `ITimeProvider.h`) with no IDF dependencies. +- **Rationale**: Host tests exercise the REAL enforcement logic (not a parallel + mock implementation) — constitution II; PR-11 reuses the same interfaces + component for sensors/controller. Mirrors the Arduino class layout + (`WaterPump.cpp` logic + GPIO) so the parity checklist maps 1:1. +- **Alternatives**: free-standing `PumpEngine` + composition — equivalent + testability, one more type with no added value here; rejected for simplicity. + Putting interfaces inside `actuators` — couples PR-03..05 to the actuator + component; rejected. +- **Target-conditional build**: `GpioWaterPump.cpp` and the `esp_driver_gpio` + dependency are excluded when `IDF_TARGET=linux` (CMake conditional in the + component's `idf_component_register`). + +## D3. Time source — injected monotonic milliseconds, polled enforcement + +- **Decision**: `ITimeProvider::nowMs()` returning monotonic `int64_t` ms. + Target implementation `EspTimeProvider` wraps `esp_timer_get_time()/1000`; + host tests use `FakeTimeProvider` (manual advance). Max-runtime/timed-run + enforcement happens in `WaterPump::update()`, polled from the main loop + (and later from the controller task, PR-11). +- **Rationale**: Deterministic host tests (advance fake clock, assert state); + `int64_t` µs→ms monotonic cannot wrap in device lifetime (edge case in spec + satisfied by type choice); polled update matches the Arduino pattern + (`WaterPump::update()`), keeping parity reasoning simple. +- **Alternatives**: `esp_timer` one-shot callbacks — introduces timer-task + concurrency into a safety path and is not host-simulatable in v6.0.1; rejected. + +## D4. Serial diagnostic — esp_console REPL + +- **Decision**: IDF-bundled `console` component, UART REPL + (`esp_console_new_repl_uart` + `esp_console_cmd_register`), prompt `ws>`, + one command: `pump |stop|status>`. + Lives in `firmware/main/diag_console.cpp`. +- **Rationale**: Bundled (constitution III), all-in-one setup, gives `help` for + free; replaces the Arduino ad-hoc serial parser as the rig-testing tool (full + FR12 diagnostics arrive in later phases). No mandatory Kconfig on classic ESP32. +- **Alternatives**: raw UART read loop (parity with Arduino) — more code, no + completion/help; rejected. + +## D5. Constants, not Kconfig (yet) + +- **Decision**: Max runtimes are `constexpr` in the actuators component + (`kMaxRunTimeMs = 300'000` per pump instance config); pin numbers and feature + flags stay exclusively in `components/board`. No new Kconfig options. +- **Rationale**: Runtime configurability arrives with NVS config (PR-06); + build-time board facts belong in board.h (FR-001); adding Kconfig now would + create a third configuration source to reconcile later. + +## D6. Mocks placement + +- **Decision**: `MockWaterPump` and `FakeTimeProvider` are header-only under + `firmware/components/actuators/include/actuators/testing/` — reusable by PR-11's + host suite, zero cost on target (never included there). +- **Alternatives**: separate `mocks` component (more plumbing, no benefit now); + test-app-local copies (blocks PR-11 reuse); both rejected. diff --git a/specs/002-pump-gpio-board/spec.md b/specs/002-pump-gpio-board/spec.md new file mode 100644 index 0000000..07f9d04 --- /dev/null +++ b/specs/002-pump-gpio-board/spec.md @@ -0,0 +1,194 @@ +# Feature Specification: Pump Actuator Layer and Board Abstraction + +**Feature Branch**: `002-pump-gpio-board` + +**Created**: 2026-06-10 + +**Status**: Draft + +**Input**: User description: "Pump actuator layer and board abstraction for ESP-IDF firmware, per docs/prd/PR-02-pump-gpio-board.md (authoritative mini-PRD; ground truth for behavior is docs/parity-checklist.md)." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Safe pump control on the bench rig (Priority: P1) + +Paul connects the rev1 devkit rig and powers it on. Both pumps stay off through boot +and any reset. From the serial console he can start and stop each pump individually, +start a timed run that stops by itself, and trust that no pump can ever run longer +than its maximum runtime regardless of what command started it. + +**Why this priority**: Pump control is the safety-critical core of the system — +everything later (watering logic, web control) builds on a pump layer that is +provably safe. This is also the first hardware-in-the-loop deliverable of phase 1. + +**Independent Test**: Flash the rig, observe pump outputs at boot (off), issue serial +start/stop/timed commands, verify self-stop and the hard runtime cap with a stopwatch +or scope. + +**Acceptance Scenarios**: + +1. **Given** a freshly powered or reset device, **When** boot completes, **Then** both + pump outputs are off and remain off until an explicit start command. +2. **Given** an idle pump, **When** the operator issues a timed start (e.g. 10 s), + **Then** the pump runs and stops by itself when the time elapses. +3. **Given** a running pump, **When** the operator issues stop, **Then** the pump + stops immediately. +4. **Given** a running pump (any start mode), **When** accumulated run time reaches + the maximum runtime (300 s), **Then** the pump is stopped automatically and the + stop is reported. + +--- + +### User Story 2 - One firmware, two boards (Priority: P2) + +An AI developer (or Paul) builds the firmware for either board revision. Selecting +the board at build time is the only step needed: pin mappings and feature flags +(RS485 direction pin, level-sensor polarity, current monitoring) follow automatically +from a single source of truth, and a wrong or missing board selection fails the build +rather than producing a mislabeled binary. + +**Why this priority**: The board abstraction is the contract every later driver PR +(PR-03..PR-05) builds against; getting it wrong propagates into every phase-1 PR. + +**Independent Test**: Build both board variants from clean checkout; verify each +binary used the correct pin table and feature flags via the build-time check and the +boot banner. + +**Acceptance Scenarios**: + +1. **Given** a clean checkout, **When** building with the rev1 board selected, + **Then** the build embeds the rev1 pin table (verified at compile time) and + reports the board name at boot. +2. **Given** a clean checkout, **When** building with the rev2 board selected, + **Then** the build embeds the provisional rev2 table and rev2 feature flags + (no RS485 direction pin, inverted level sensors, current monitoring present). +3. **Given** no board selected (configuration error), **When** building, **Then** + the build fails with an explicit error. + +--- + +### User Story 3 - Pump behavior testable without hardware (Priority: P3) + +An AI developer changes pump or (later) watering logic and runs the host test suite +in CI. Runtime enforcement, timed-run behavior and run-time tracking are verified +against a simulated clock — no devkit needed, failures block the merge. + +**Why this priority**: Host-testability is a constitution principle (II) and the +foundation PR-11's 100 % logic coverage will build on; the mock pump created here is +that foundation. + +**Independent Test**: Run the host test suite on a machine with no ESP32 attached; +tests for max-runtime, self-stop and runtime tracking pass deterministically. + +**Acceptance Scenarios**: + +1. **Given** the host test suite, **When** a timed run is simulated past its + duration, **Then** the pump model reports stopped and total run time equals the + configured duration. +2. **Given** a simulated run reaching the maximum runtime, **When** time advances + further, **Then** the pump model is stopped and the overrun is observable. +3. **Given** CI on a clean checkout, **When** the host tests run, **Then** they + complete without any hardware or device-specific environment. + +--- + +### Edge Cases + +- Start command to an already-running pump: must not restart the runtime clock or + extend a timed run; the call is rejected or ignored with a clear result. +- Timed start with duration 0: no indefinite runs exist in the new firmware — the + request is rejected (deliberate change; see Assumptions). +- Timed start with duration above the maximum runtime: rejected with a clear error + (consistent with duration-0 rejection; explicit rejection over silent clamping). +- Stop command to an already-stopped pump: harmless no-op, reported as success. +- Two pumps commanded simultaneously: each enforces its own runtime independently; + stopping one never affects the other. +- Reset while a pump is running (watchdog, panic, power blip): pump output returns + to off as part of boot (covered by the existing boot fail-safe; this feature must + not weaken it). +- Time source anomalies in run-time tracking (wrap-around of the tick counter) must + not cause a pump to run past its cap or stop prematurely. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The board abstraction MUST be the single source of truth for pin + mappings and board feature flags for both board revisions; application code MUST + obtain pins/flags only from it. +- **FR-002**: The rev1 pin table MUST match the running Arduino firmware as recorded + in `docs/parity-checklist.md` (I2C SDA 21 / SCL 22, RS485 TX 16 / RX 17 / DE 25, + plant pump 26, reservoir pump 27, reservoir level low 32 / high 33, status LED 2, + manual button 5, config button 18). `docs/hardware.md` MUST NOT be used as pin + source (known swapped TX/RX, checklist QUIRK 6). +- **FR-003**: The rev2 table MUST carry provisional pin numbers marked `TODO(SYNC1)` + and the feature flags: no RS485 direction pin (auto-direction transceiver), + level sensors active low (inverter), current monitoring present. +- **FR-004**: A build with no board (or a contradictory board) selection MUST fail + at compile time. The selected board MUST be verifiable per build (compile-time + check and boot banner). +- **FR-005**: Pump control MUST be exposed through hardware-independent interfaces + (ported `IActuator`/`IWaterPump`, pure C++, no Arduino or hardware-SDK types) + that host tests can use without any hardware headers. +- **FR-006**: The GPIO pump driver MUST drive the MOSFET gate active HIGH and MUST + set the output to off as its first action when constructed/initialized (before + any other use), preserving the boot fail-safe. +- **FR-007**: The driver MUST support: start (timed), stop, running-state query, + and run-time reporting (current run start, accumulated run time). +- **FR-008**: Maximum runtime MUST be enforced in the driver for every run mode: + reservoir pump 300 s (parity with Arduino `RESERVOIR_PUMP_MAX_RUNTIME`), plant + pump 300 s for manual runs (**deliberate behavior change** — the Arduino firmware + allows uncapped/indefinite manual runs; see `docs/parity-checklist.md` §4 and + `docs/prd/PR-02`). Indefinite runs are not supported. +- **FR-009**: Reaching the maximum runtime MUST stop the pump autonomously (no + cooperation from callers required) and the event MUST be observable (log + state). +- **FR-010**: Two pump instances (plant, reservoir) MUST be wired in the application + behind the interfaces, each with its own configuration and independent state. +- **FR-011**: A serial diagnostic MUST allow starting (timed) and stopping each pump + and querying pump status on the rig, for HIL verification. +- **FR-012**: A mock pump implementation MUST exist for host tests, driven by an + injectable/simulated time source, sufficient to test max-runtime enforcement, + self-stop and runtime tracking deterministically in CI. + +### Key Entities + +- **Board profile**: pin mapping + feature flags for one board revision; exactly one + active per build; compile-time selected. +- **Pump (actuator)**: identity (plant/reservoir), output state, run mode (timed), + configured duration, maximum runtime, run-time statistics. +- **Time source**: monotonic time used for timed runs and enforcement; injectable so + hosts can simulate it. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Both board variants build green in CI from a clean checkout, and each + build proves it selected the intended board (automated check, no human inspection). +- **SC-002**: On the rig, pump outputs are off at boot and after reset in 100 % of + observed boots (HIL checklist). +- **SC-003**: A timed pump run self-stops within 1 s of its configured duration, and + no pump ever exceeds its 300 s cap in any test. +- **SC-004**: Host test suite covering runtime enforcement and tracking runs in CI + with zero hardware dependencies and passes deterministically (no flaky reruns). +- **SC-005**: Operator can start/stop/query each pump over the serial console using + documented commands on the first attempt (HIL checklist). + +## Assumptions + +- Indefinite pump runs are intentionally removed: every run is timed and capped at + 300 s. This resolves the Arduino "duration 0 = indefinite manual run" semantics by + rejection of duration 0 (the operator simply issues a new timed run if needed). + Recorded as a deliberate behavior change in `docs/parity-checklist.md` §4 / PR-02. +- The plant pump's automatic-mode watering duration remains configuration-capped at + 1–300 s (parity); the driver-level cap is a second, independent safety net. +- Rev2 provisional pins reuse the rev1 numbers where applicable until SYNC 1; only + the feature flags differ today. Final rev2 numbers land at SYNC 1 (gates PR-14, + not this PR). +- The mode-flag semantics quirk (QUIRK 1 — Arduino flags auto runs as manual) is + resolved at the controller level in PR-11; this PR's driver exposes neutral + timed-run semantics and does not encode manual/automatic policy. +- The status LED and button pins enter the board table now (they are board facts) + but their behaviors are implemented in later PRs. +- Serial diagnostic is a temporary rig tool; the full diagnostic command set (FR12 + of the master PRD) arrives with later phases. diff --git a/specs/002-pump-gpio-board/tasks.md b/specs/002-pump-gpio-board/tasks.md new file mode 100644 index 0000000..35b4302 --- /dev/null +++ b/specs/002-pump-gpio-board/tasks.md @@ -0,0 +1,100 @@ +# Tasks: Pump Actuator Layer and Board Abstraction + +**Input**: Design documents from `/specs/002-pump-gpio-board/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md + +**Tests**: Host tests are an explicit deliverable of this feature (spec US3, FR-012, +SC-004) — they are required, not optional. + +**Organization**: Tasks grouped by user story. US1 = safe pump control on rig (P1), +US2 = one firmware/two boards (P2), US3 = host-testable pump behavior (P3). + +## Phase 1: Setup + +- [x] T001 Create `interfaces` component skeleton: `firmware/components/interfaces/CMakeLists.txt` (header-only registration, no dependencies) +- [x] T002 Create `actuators` component skeleton: `firmware/components/actuators/CMakeLists.txt` with target-conditional sources (exclude `GpioWaterPump.cpp` and the `esp_driver_gpio` dependency when `IDF_TARGET=linux`, per research.md D2) + +## Phase 2: Foundational (blocking prerequisites for all stories) + +- [x] T003 [P] Port `IActuator` to pure C++ in `firmware/components/interfaces/include/interfaces/IActuator.h` (from Arduino `include/actuators/IActuator.h`; `std::string`, no Arduino types; contract per `contracts/iwaterpump.md`) +- [x] T004 [P] Port `IWaterPump` with the new contract (runFor rejection rules, `update()`, runtime statistics, `StopReason`) in `firmware/components/interfaces/include/interfaces/IWaterPump.h` +- [x] T005 [P] Create `ITimeProvider` (monotonic `int64_t nowMs()`) in `firmware/components/interfaces/include/interfaces/ITimeProvider.h` +- [x] T006 Implement `WaterPump` base class (ALL timing/safety logic: state machine per data-model.md, paired `applyOutput` transitions, max-runtime 300 s enforcement in `update()`, accumulated runtime, `lastStopReason`) in `firmware/components/actuators/include/actuators/WaterPump.h` + `firmware/components/actuators/src/WaterPump.cpp` +- [x] T007 [P] Implement `EspTimeProvider` (`esp_timer_get_time()/1000`) in `firmware/components/actuators/include/actuators/EspTimeProvider.h` + +**Checkpoint**: Foundation ready — all three user stories can now start. + +## Phase 3: User Story 1 — Safe pump control on the bench rig (P1) 🎯 MVP + +**Goal**: Pumps provably off at boot; serial start/stop/timed control with hard +300 s cap on the rev1 rig. + +**Independent Test**: Flash rig → boot-off check on GPIO 26/27 → serial commands +per `contracts/serial-diagnostic.md` HIL mapping. + +- [x] T008 [US1] Implement `GpioWaterPump` (`applyOutput` → `gpio_set_level`, active HIGH; `initialize()` forces OFF before anything else) in `firmware/components/actuators/include/actuators/GpioWaterPump.h` + `firmware/components/actuators/src/GpioWaterPump.cpp` +- [x] T009 [US1] Wire two pump instances ("plant" GPIO 26, "reservoir" GPIO 27, shared `EspTimeProvider`) behind `IWaterPump` in `firmware/main/app_main.cpp`; poll `update()` in the main loop (≥ 10 Hz); keep the existing boot fail-safe FIRST and unchanged +- [x] T010 [US1] Implement `esp_console` REPL with `pump |stop|status>` + `pump status` per `contracts/serial-diagnostic.md` in `firmware/main/diag_console.cpp`; register `console` dependency in `firmware/main/CMakeLists.txt` +- [x] T011 [US1] Verify rev1 build in the pinned container (quickstart §1) and fix anything broken; confirm boot banner + REPL prompt appear and no pump GPIO goes high at boot (log inspection) + +**Checkpoint**: US1 delivers the MVP — rig-controllable, safety-capped pumps. + +## Phase 4: User Story 2 — One firmware, two boards (P2) + +**Goal**: Board component is the complete single source of truth; wrong/missing +board selection cannot produce a binary. + +**Independent Test**: Clean-checkout builds of both variants; compile-time pin-table +check; boot banner names the right board. + +- [x] T012 [P] [US2] Extend both board tables in `firmware/components/board/include/board/board.h`: status LED 2, manual button 5, config button 18 (rev2 values provisional with `TODO(SYNC1)`), keeping the FROZEN-doc warning comments and `#error` guard intact +- [x] T013 [US2] Add compile-time board sanity checks (e.g. `static_assert` on pin distinctness and flag consistency — `BOARD_HAS_RS485_DE` ⟺ DE macro defined) in `firmware/components/board/include/board/board.h` +- [x] T014 [US2] Verify BOTH board variants build green in the pinned container with correct `CONFIG_BOARD_*` in generated sdkconfig (quickstart §1); update `firmware/README.md` board table if pins were added + +**Checkpoint**: US2 complete — board contract ready for PR-03..05. + +## Phase 5: User Story 3 — Pump behavior testable without hardware (P3) + +**Goal**: Real enforcement logic verified deterministically in CI with zero hardware. + +**Independent Test**: `idf.py --preview set-target linux && idf.py build && ./build/*.elf` +exits 0 on a machine with no ESP32. + +- [x] T015 [P] [US3] Create `MockWaterPump` (records `applyOutput` transitions) and `FakeTimeProvider` (manual `advance()`) header-only in `firmware/components/actuators/include/actuators/testing/MockWaterPump.h` and `.../testing/FakeTimeProvider.h` +- [x] T016 [US3] Create host test app (linux preview target, `set(COMPONENTS main)` isolation, plain Unity runner with `std::exit(UNITY_END())`) in `firmware/test_apps/host/CMakeLists.txt`, `firmware/test_apps/host/sdkconfig.defaults`, `firmware/test_apps/host/main/CMakeLists.txt` per research.md D1 +- [x] T017 [US3] Write Unity tests in `firmware/test_apps/host/main/test_water_pump.cpp` covering: duration self-stop, max-runtime forced stop (+ reason + log), rejected starts (0 s, > 300 s, already-running — no output/state change), stop-when-stopped no-op, paired output transitions (invariant 1), accumulated runtime tracking, enforcement within one poll +- [x] T018 [US3] Run the host suite in the pinned container (quickstart §2), fix until exit 0 (9/9 PASS, exit 0) +- [x] T019 [US3] Add `host-test` job to `.github/workflows/firmware-build.yml` (esp-idf-ci-action, path `firmware/test_apps/host`, command per research.md D1; `if-no-files-found`/failure semantics consistent with existing jobs; include `firmware/test_apps/**` in trigger paths) + +**Checkpoint**: All user stories complete. + +## Phase 6: Polish & Cross-Cutting + +- [x] T020 [P] Update `firmware/CLAUDE.md` (new components, host-test commands, console diagnostic) and `firmware/README.md` (host-test section per quickstart) +- [x] T021 [P] Write the HIL test checklist for CP3 (Paul runs on rig) in `specs/002-pump-gpio-board/checklists/hil.md` from `contracts/serial-diagnostic.md` §HIL mapping +- [ ] T022 Self-review pass: parity-checklist cross-check (§1/§2 items this PR claims), constitution gates re-check, no stray `esp_timer` in host-tested code + +## Dependencies + +``` +Phase 1 (T001-T002) ──► Phase 2 (T003-T007) ──► US1 (T008-T011) ──► Polish (T020-T022) + ├──► US2 (T012-T014) ──┤ + └──► US3 (T015-T019) ──┘ +US1, US2, US3 are mutually independent after Phase 2. +Within US3: T015 ──► T016 ──► T017 ──► T018 ──► T019. +Within US1: T008 ──► T009 ──► T010 ──► T011. +``` + +## Parallel Execution Examples + +- After Phase 2: T008 (US1), T012 (US2) and T015 (US3) touch disjoint files — parallelizable. +- Within Phase 2: T003, T004, T005, T007 are independent headers [P]; T006 depends on T003-T005. +- Polish: T020 and T021 are independent [P]. + +## Implementation Strategy + +MVP first: Phases 1-3 (US1) give a flashable, rig-testable increment. US2 and US3 +can then land in either order; US3 is the CI quality gate and should not be skipped +before review. Single implementer agent executes sequentially in this order: +Setup → Foundational → US1 → US2 → US3 → Polish (parallelism not worth the +coordination cost at this size).