Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/firmware-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
3 changes: 3 additions & 0 deletions .specify/feature.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"feature_directory": "specs/002-pump-gpio-board"
}
54 changes: 51 additions & 3 deletions firmware/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand All @@ -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 <plant|reservoir> start <seconds> # timed run; 1..300
pump <plant|reservoir> stop
pump <plant|reservoir> status
pump status # both pumps
```

## Board abstraction

Two board revisions exist, selected via Kconfig (`main/Kconfig.projbuild`):
Expand Down
38 changes: 38 additions & 0 deletions firmware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <plant|reservoir> start <seconds> # timed run; 1..300
pump <plant|reservoir> stop
pump <plant|reservoir> 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,
Expand Down
21 changes: 21 additions & 0 deletions firmware/components/actuators/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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()
31 changes: 31 additions & 0 deletions firmware/components/actuators/include/actuators/EspTimeProvider.h
Original file line number Diff line number Diff line change
@@ -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 <cstdint>

#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 */
56 changes: 56 additions & 0 deletions firmware/components/actuators/include/actuators/GpioWaterPump.h
Original file line number Diff line number Diff line change
@@ -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 <string>

#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 */
Loading
Loading