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
74 changes: 74 additions & 0 deletions firmware/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,80 @@ The server is constructed in `app_main` and `start()`ed on the first
interface); `start()`/`stop()` are idempotent and non-fatal. HIL checklist:
`specs/009-http-server-api-v1/checklists/hil.md`.

## Watering controller (feature 011)

Feature 011 (PR-11) adds the `control` component — the automatic watering
logic, 100 % host-tested pure C++ over the interfaces (the master-PRD
criterion). No IDF/`esp_*` includes; all time is injected (`ITimeProvider`
monotonic for gates/staleness, `IWallClock` epoch for log timestamps) and every
threshold/duration is read from `IConfigStore` each tick (runtime-tunable). Two
classes, both driven over mocks + `FakeTimeProvider`/`FakeWallClock` in
`test_apps/host/main/test_watering_controller.cpp` + `test_reservoir.cpp`:

- **`WateringController`** — pulsed automatic watering. `tick()` order is
safety-first: `plant.update()` (self-stop + 300 s cap) → burst-end detection →
single soil read → periodic data-log → manual-override branch → enabled gate →
**fail-safe** → gate-on-read → watering decision. Start at moisture ≤ low when
the soak pause has elapsed; stop at ≥ high. **Fail-safe (soil unavailable /
stale > 30 000 ms / moisture out of 0–100 %) is checked BEFORE the soak gate
and is never delayed by it** — a pending soak pause never postpones a safety
stop. Automatic decisions gate on a successful in-range read, never on
placeholder/last-good values.
- **Soak-pause divergence (parity):** the min-watering-interval soak is measured
from the burst **END** (true absorption), and it is ENFORCED — no new burst
starts until it elapses even while the soil still reads dry. The burst-end
edge is detected whether the pump self-stopped (duration/cap) or was stopped
at the high threshold.
- **Manual override:** `startManual(int)` clamps to 1..300 s, runs the plant
pump and sets a flag that exempts the run from the automatic fail-safe;
`stop()` clears it; a pump self-stop clears it on the next tick. Manual is
`wateringEnabled == false` at the API layer — the controller never calls an
`isManualMode()` on the pump.
- **`ReservoirController`** (rev1 only, but host-tested regardless of board) —
the level truth table over two `ILevelSensor` snapshots (invalid or
implausible dry+wet rows → no action; dry+dry → fill; high-wet → stop),
running-safety stop-on-high, the pump's 300 s cap for the max-fill abort, and
a **post-abort cooldown** (`kReservoirRefillCooldownMs`, ~60 s): after a
`MaxRuntimeForced` abort no new automatic fill starts until the cooldown
elapses even if still low-dry — guards a stuck high sensor / empty source. A
normal high-wet stop does not arm the cooldown; manual fill bypasses it. This
cooldown is a deliberate divergence from parity (`docs/parity-checklist.md`).
- **Logging (FR-014):** every `dataLogInterval` the controller logs env
(`env_temperature/humidity/pressure`) + soil (`soil_moisture/temperature/ph/
ec`, plus NPK only when ≥ 0), stamped with `IWallClock::nowEpoch()` and gated
on `isTimeSet()` (never a bogus 1970). `soil_humidity` is intentionally NOT
logged — `ISoilSensor::getHumidity()` is identical to `getMoisture()` (parity,
register 0x0000), so the max distinct-metric set is exactly 10 = the
`IDataStorage` `kMaxMetrics` cap, no data loss. Fail-safe events go through
`EventLogger::logFailsafe`; pump start/stop transitions stay owned by
`SystemObserver` (no double-log).

**Snapshot helpers:** `LockedSoilSensor::snapshot()` / `LockedEnvironmentalSensor`
/ `LockedLevelSensor` return `{Soil,Env,Level}Snapshot` — all values + validity
(plus the error code for `SoilSnapshot`) copied out under ONE lock, closing the
read()-then-getter cross-call gap without a fresh (blocking) bus read (QUIRK 5).

**On-target wiring:** the pure logic runs on `main/watering_task.cpp`, a
watchdog-subscribed FreeRTOS task ticking at `config.getSensorReadIntervalMs()`
(floored at 1 s). The controller is the **periodic soil reader** (controller-as-
reader): its per-tick `read()` is the blocking Modbus transaction that refreshes
the `LockedSoilSensor` cache the API `/sensors` endpoint serves — so there is no
separate soil-reader task, and the blocking bus I/O is isolated off the 10 Hz
safety loop (which still owns precise pump-timing enforcement + `observer.poll()`).
The API mode flag reaches the controller purely through `config`
(`getWateringEnabled()`, read each tick) — no direct ApiServer↔controller call
(FR-017 isolation). Reservoir (rev1): `tick(true, getWateringEnabled())` — the
pump always exists so `enabled` is always true; auto level control is gated by
the same mode flag (manual mode suspends auto-fill; manual API fills still work).
There is deliberately no dedicated auto-level config flag.

**RS485 race fix (T016):** `LockedModbusClient` (header-only `IModbusClient`
decorator) wraps the `EspModbusClient` in `app_main`; the `ModbusSoilSensor` and
the console `rs485test` command now share one mutex, so the periodic reader and
the diagnostic probe can no longer overlap bus transactions. Lock order is always
(soil mutex → modbus mutex) or (modbus mutex alone) — no deadlock. HIL checklist:
`specs/011-watering-controller-host-tests/checklists/hil.md`.

## Partition layout (4MB flash)

nvs (0x9000, 16K) | otadata (0xd000, 8K) | phy_init (0xf000, 4K) |
Expand Down
14 changes: 14 additions & 0 deletions firmware/components/control/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# control — pure watering application logic (feature 011).
#
# Holds the pure, host-tested WateringController (US1) and ReservoirController
# (US2). Both depend ONLY on the header-only `interfaces` component and the
# pure `events` EventLogger — no IDF/esp_* code, no esp_timer (time is injected
# via ITimeProvider/IWallClock). The component therefore compiles identically
# on the linux host (host-tested against the mocks + fakes) and on both device
# targets. No linux guard needed.
idf_component_register(
SRCS "src/WateringController.cpp"
"src/ReservoirController.cpp"
INCLUDE_DIRS "include"
REQUIRES interfaces events
)
3 changes: 3 additions & 0 deletions firmware/components/control/include/control/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Placeholder so include/control/ exists for the control component's
# INCLUDE_DIRS before WateringController.h / ReservoirController.h land
# (US1/US2). Remove once real headers are added.
146 changes: 146 additions & 0 deletions firmware/components/control/include/control/ReservoirController.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// SPDX-FileCopyrightText: 2026 Cryptotomte
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* @file ReservoirController.h
* @brief Pure reservoir auto-fill state machine (level truth table + safety).
*
* Feature 011 (PR-11), User Story 2. Board-independent: the whole state
* machine is host-tested regardless of which board wires the fill pump — the
* BOARD_HAS_RESERVOIR_PUMP capability flag gates only the construction/wiring
* in app_main, never this logic (FR-013). Composed from TWO independent
* ILevelSensor marks (low + high), one IWaterPump (the fill pump), an injected
* ITimeProvider (monotonic time for the post-abort cooldown) and the pure
* EventLogger. Normative contract:
* specs/011-watering-controller-host-tests/contracts/reservoir-controller.md;
* decision table + constants: .../data-model.md.
*
* This class MUST NOT include esp_* / esp_timer or make any hardware/network
* call: monotonic time comes from ITimeProvider and every input is an injected
* interface, so it is exercised on the IDF linux preview target against the
* mocks/fakes (test_reservoir.cpp).
*
* Concurrency: the controller is unsynchronized and single-writer (its own
* watchdog-registered task). On-target US3 drives the shared level sensors and
* pump through the Locked* wrappers; the pure US2 logic drives the injected
* interfaces directly.
*/

#ifndef WATERINGSYSTEM_CONTROL_RESERVOIRCONTROLLER_H
#define WATERINGSYSTEM_CONTROL_RESERVOIRCONTROLLER_H

#include <cstdint>

#include "events/EventLogger.h"
#include "interfaces/ILevelSensor.h"
#include "interfaces/ITimeProvider.h"
#include "interfaces/IWaterPump.h"

/**
* @brief Reservoir auto-fill with running safety and a post-abort cooldown.
*
* Invariants (host-tested):
* 1. An invalid level sensor is NEVER treated as "water absent": if either
* mark is not-yet-valid/invalid the controller takes no automatic action
* (FR-012).
* 2. Running safety is unconditional: whenever the fill pump runs (auto or
* manual), the high mark reading wet stops it immediately, and the pump's
* own update() aborts it at the hard 300 s max-fill cap.
* 3. After a fill ends by the max-runtime abort (StopReason::MaxRuntimeForced)
* no new AUTOMATIC fill starts until kReservoirRefillCooldownMs has elapsed
* — even while the water still reads low — preventing an endless 300 s
* cycle on a stuck high sensor or an empty source (FR-012a). A normal
* high-wet stop does NOT arm the cooldown; a manual fill bypasses it.
* 4. The feature gate forces the pump OFF and skips all logic when the
* reservoir feature is disabled / absent on the board (FR-013).
*/
class ReservoirController {
public:
/// Cooldown after a max-runtime abort before another AUTOMATIC fill may
/// start (FR-012a). Documented constant, tunable; a manual fill bypasses
/// it and a normal high-wet stop never arms it.
static constexpr int64_t kReservoirRefillCooldownMs = 60'000;

/// Automatic fill duration, in seconds. Deliberately equal to the pump's
/// hard max-runtime cap (WaterPump::kDefaultMaxRunTimeMs == 300 s): an
/// auto fill runs until the high mark trips (running safety stops it early)
/// or, if the high mark never trips, until the cap aborts it as
/// StopReason::MaxRuntimeForced. The IWaterPump interface does not expose
/// its cap, so the duration is pinned here to match it (contract: "fill
/// duration + the 300 s cap come from the pump").
static constexpr int kReservoirFillDurationS = 300;

/**
* @brief Construct over injected collaborators (references; must outlive).
*
* Constructing with sensors that fail to initialize is safe: no work runs
* in the constructor and tick() simply takes no action while a mark is
* invalid (FR-012/FR-015).
*/
ReservoirController(ILevelSensor& lowMark, ILevelSensor& highMark,
IWaterPump& fillPump, ITimeProvider& clock,
EventLogger& events)
: lowMark_(lowMark),
highMark_(highMark),
fillPump_(fillPump),
clock_(clock),
events_(events)
{
}

ReservoirController(const ReservoirController&) = delete;
ReservoirController& operator=(const ReservoirController&) = delete;

/**
* @brief One reservoir evaluation. Non-blocking; call at a fixed cadence.
*
* Order (safety first): fill-pump self-stop/cap enforcement → arm the
* post-abort cooldown on a max-runtime abort edge → feature gate (force off
* when disabled) → running safety (stop on high-wet) → when enabled + auto
* and the pump is not running, evaluate the level truth table (respecting
* the cooldown for the dry/dry start).
*
* @param enabled reservoir feature enabled (board has the pump).
* @param autoLevelControl automatic level control active (else manual only).
*/
void tick(bool enabled, bool autoLevelControl);

/**
* @brief Start a manual fill (explicit operator override).
*
* Refused when the reservoir is already full (the high mark reads wet).
* Bypasses the post-abort cooldown. The duration is bounded by the pump's
* runFor() contract (1..300 s; out-of-range requests are rejected, no
* silent clamping); running safety and the 300 s cap still apply while it
* runs.
*
* @param durationS requested fill duration in seconds.
* @return true if the fill was started, false if refused/rejected.
*/
bool startManualFill(int durationS);

/// Stop the fill pump (any mode).
void stop();

private:
/// Arm the post-abort cooldown when the fill pump self-stopped at the hard
/// max-runtime cap during this tick's update() — a running->stopped edge
/// with StopReason::MaxRuntimeForced. A normal Commanded/high-wet stop is
/// issued AFTER this check, so it is never mistaken for an abort.
void armCooldownOnAbortEdge(bool wasRunning, int64_t now);

/// Evaluate the level truth table (auto mode, pump not running).
void evaluateAuto(int64_t now);

ILevelSensor& lowMark_;
ILevelSensor& highMark_;
IWaterPump& fillPump_;
ITimeProvider& clock_;
EventLogger& events_; ///< US3 event-logging seam (unused in US2)

/// Monotonic time of the last max-runtime abort (0 = none). While
/// now - lastAbortMs_ < kReservoirRefillCooldownMs, an automatic fill is
/// suppressed even when the water reads low (FR-012a).
int64_t lastAbortMs_ = 0;
};

#endif /* WATERINGSYSTEM_CONTROL_RESERVOIRCONTROLLER_H */
Loading
Loading