Skip to content

PR-11: Watering controller (host-tested application logic)#16

Merged
cryptotomte merged 9 commits into
mainfrom
011-watering-controller-host-tests
Jul 6, 2026
Merged

PR-11: Watering controller (host-tested application logic)#16
cryptotomte merged 9 commits into
mainfrom
011-watering-controller-host-tests

Conversation

@cryptotomte

Copy link
Copy Markdown
Owner

Feature 011 — the watering-decision layer, the master-PRD's 100 %-host-tested logic PR. New pure control component driven entirely over the interfaces + injected clocks.

What's here

  • WateringController — pulsed automatic watering: start at moisture ≤ low when the soak pause (measured from the burst END, enforced — FR-003 divergence) has elapsed; stop at ≥ high. Fail-safe (soil unavailable / stale > 30 s / moisture out of range) is checked BEFORE the soak gate and never delayed by it. Manual override (cap 300 s, exempt from fail-safe). Periodic env+soil data-logging (epoch-stamped, gated on isTimeSet(), NPK only when ≥ 0; soil_humidity omitted as it duplicates soil_moisture, keeping the set at exactly kMaxMetrics=10).
  • ReservoirController (rev1) — level truth table (invalid/implausible → no action), stop-on-high, 300 s max-fill abort, and a post-abort cooldown (FR-012a, the approved divergence — guards a stuck high sensor / empty source). Host-tested regardless of board.
  • On-target wiringmain/watering_task.cpp, a watchdog-subscribed task at the sensor cadence. The controller-as-reader does the periodic soil read (refreshing the /sensors cache), isolated off the 10 Hz pump-timing loop. The API mode flag reaches the controller only through config (FR-017 isolation).
  • LockedModbusClient — serializes the console rs485test probe with the periodic reader (fixes the documented cross-task RS485 race).
  • snapshot() promoted to ISoilSensor — the controller reads once then consumes one coherent, non-blocking snapshot (no redundant blocking isAvailable() bus probe).

Verification

  • Host suite: 293 / 0 (41 new: 26 watering + 15 reservoir) — every decision/safety branch mapped in specs/011-.../checklists/branch-coverage.md.
  • rev1 + rev2 builds green; app ~1037 / 1039 KiB in the 1536 KiB OTA slot (~32 % margin). No new managed deps.
  • Full spec-kit flow: spec → clarify → plan → tasks → implement → CP3 review (5 agents) → fixes → verifying re-review (approved, no regressions). Checkpoint record: docs/checkpoints/cp3-011-watering-controller.md.

HIL

Deferred to the rev1 bench rig (specs/011-.../checklists/hil.md) — the merge gate is the host suite + both board builds, per the project's host-test-PR policy.

Safety

pumps_force_off() remains first in app_main; the pure control component has no esp_* includes; the 10 Hz enforcement loop is unchanged. Merge with a merge commit (never squash — spec-kit branch chaining).

🤖 Generated with Claude Code

cryptotomte and others added 9 commits July 5, 2026 19:00
Spec-kit artifacts for PR-11 (pure, 100%-host-tested WateringController + ReservoirController),
approved at Checkpoint 2. Records the resolved decisions: soak gate enforced (from burst END),
manual cap 300s, QUIRK-1 mode semantics, reservoir truth table + invalid-row + post-abort
cooldown (FR-012a, deliberate divergence), fail-safe never delayed by the gate, 30s staleness,
snapshot-helper enablers, periodic soil reader. No implementation yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hot helpers

Phase 1+2 of PR-11:
- control component skeleton (pure, header-only until US1/US2 sources land)
- consistent-snapshot helpers on LockedSoil/Env/LevelSensor (one locked call ->
  {validity + values}, closing the read()-then-getters cross-call gap; non-blocking,
  snapshot caches read() outcome, no bus I/O). Resolves the TODO(PR-11) markers.
- MockSoilSensor scriptSuccessfulRead/scriptFailedRead (coherent FIFO, back-compat)
- host control test suites registered (stubs)

Verified: host 252/0, rev1 + rev2 build (0 errors) + board-config verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-safe (host-tested)

Phase 3 / US1 (pure, host-tested MVP):
- WateringController.tick(): fail-safe FIRST (soil unavailable / stale >30s / invalid
  moisture in auto -> immediate stop + logFailsafe, never delayed by the soak gate),
  then the watering decision (start at <=low when soak elapsed; stop at >=high). Soak
  measured from burst END (edge-detects the pump's own timed self-stop). Thresholds/
  duration/soak read from config each tick. Gate on read result, not placeholder.
  lastValidSoilMs recorded before the stale test (fixes a first-read deadlock).
- 14 host tests (8 automatic/soak + 6 fail-safe incl. not-delayed-by-soak + graceful degradation)

On-target read()/isAvailable() vs snapshot integration is US3. Reservoir is US2.
Verified: host 266/0 (+14), rev1 + rev2 build + board-config verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pure, board-independent reservoir fill controller:
- level truth table (invalid/implausible rows -> no action; dry+dry -> fill)
- running safety: stop on high-wet; pump cap aborts at 300 s (MaxRuntimeForced)
- post-abort cooldown (kReservoirRefillCooldownMs=60 s): after a max-runtime
  abort, suppress a new auto fill until cooldown elapses even if still low-dry
  (guards a stuck high sensor / empty source); normal high-wet stop does not
  arm it; manual fill bypasses it
- feature gate: !enabled forces the pump off and skips all logic

14 host tests (test_reservoir.cpp): all truth-table rows, stop-on-high,
max-fill abort, disabled-forces-off, cooldown arm/bypass. Host 280/0; rev1 +
rev2 build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WateringController gains the operator-override and telemetry-logging paths
(still pure, host-tested):
- startManual(int) clamps to [1,300] s, runs the plant pump, sets an override
  flag that exempts the run from the automatic fail-safe; stop() clears it.
  A pump self-stop (duration/cap) clears the override on the next tick.
- tick() now logs env+soil telemetry via maybeLogData() BEFORE the mode /
  fail-safe branches, so telemetry is recorded regardless of watering state.
  Soil is read once per tick and reused by both logging and the decision.
- maybeLogData() gates on IWallClock::isTimeSet() (never logs a 1970 epoch),
  honours getDataLogIntervalMs(), stamps IWallClock::nowEpoch(), logs env
  (temperature/humidity/pressure) and soil (moisture/temperature/ph/ec) plus
  NPK only when >= 0. soil_humidity is intentionally NOT logged: getHumidity()
  is identical to getMoisture() (parity), so the max distinct-metric set is
  exactly 10 = kMaxMetrics (3 env + 4 soil base + 3 NPK), no data loss.

Constructor now injects IEnvironmentalSensor& (env) so the controller is the
single host-tested owner of periodic data logging.

+9 host tests (manual bypass/clamp/clear, auto-not-manual, log cadence/epoch/
NPK filter/time-not-set gate/failsafe-path). Host 289/0; rev1 + rev2 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…arget

Adds the decision-layer watering task and constructs the controllers in
app_main (T013/T014/T015):
- new main/watering_task.{h,cpp}: a watchdog-subscribed FreeRTOS task that
  ticks at config.getSensorReadIntervalMs() (floored at 1 s). Each cycle it
  calls WateringController::tick() and (rev1) ReservoirController::tick(). The
  controller-as-reader does the periodic soil Modbus read here, refreshing the
  LockedSoilSensor cache the PR-09 /sensors endpoint serves (this is why no
  separate soil-reader task is needed). Blocking bus I/O is isolated on this
  task, off the 10 Hz safety loop.
- app_main constructs WateringController (+ ReservoirController on rev1) as
  function-local statics over the live Locked* wrappers, after the sensors and
  before console registration, and starts the watering task after sensor_task.
  pumps_force_off() stays first; the 10 Hz enforcement loop is unchanged (it
  still owns precise pump-timing). The API mode flag reaches the controller
  purely through config (getWateringEnabled(), read each tick) — no direct
  ApiServer<->controller call.
- reservoir tick mapping (rev1): tick(true, getWateringEnabled()) — the pump
  always exists so enabled is always true; automatic level control is gated by
  the same mode flag, so manual mode suspends auto-fill while manual API fills
  still work. There is deliberately no dedicated auto-level config flag.
- main/CMakeLists: +watering_task.cpp, +control in PRIV_REQUIRES.

rev1 + rev2 build green; host suite unchanged (289/0, control component
untouched).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The diag console rs485test command drove the raw EspModbusClient directly,
bypassing LockedSoilSensor's mutex. With PR-11's periodic soil reader now
issuing bus transactions from the watering task, that was a cross-task RS485
bus-transaction race (documented TODO at diag_console rs485test).

Fix: new header-only LockedModbusClient (IModbusClient decorator, one mutex
around every call, same pattern as LockedSoilSensor/LockedWaterPump). app_main
wraps the EspModbusClient in it and injects the wrapper everywhere — the
ModbusSoilSensor's transactions and rs485test now share one mutex, so no two
bus transactions overlap. Lock order is always (soil mutex -> modbus mutex) or
(modbus mutex alone); the wrapper never calls back into the sensor, so no
deadlock. Per-transaction serialization suffices: interleaving complete
request/response transactions on the bus is safe, only overlapping ones corrupt.

rs485test code is unchanged (the fix is purely the injected locked instance);
its stale TODO comment is updated to note the resolution.

rev1 + rev2 build green; host suite unchanged (289/0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e checklists

- firmware/CLAUDE.md: new "Watering controller (feature 011)" section (control
  component, pure/host-tested logic, soak-from-burst-end + enforcement, fail-safe
  precedence, manual=flag+300 s cap, reservoir truth table + cooldown divergence,
  data-log metric set incl. why soil_humidity is dropped, snapshot helpers,
  controller-as-reader periodic soil, watering task + watchdog, LockedModbusClient
  race fix).
- specs/011/checklists/hil.md: rev1 bench-rig smoke (live soil, automatic + soak,
  fail-safe precedence, manual override, reservoir fill/stop/abort/cooldown,
  logging, isolation, rs485 race) with the deferral path.
- specs/011/checklists/branch-coverage.md: every decision/safety branch mapped to
  its covering host test; records the green run (289/0, both boards, sizes).

Size (T019): rev1 1037.3 KiB, rev2 1039.4 KiB in the 1536 KiB OTA slot (~32 %
margin). No new managed deps (T018).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gaps

Addresses the Checkpoint-3 review findings.

M1 (watchdog boot-loop): watering_task fed the WDT once per loop while sleeping
the full, operator-configurable getSensorReadIntervalMs() — set above the ~20 s
task-WDT timeout it would panic-reboot and boot-loop (NVS-persisted). Now uses
chunked feed-and-sleep (1 s slices, feed each) and ticks once per full period,
so a large configured cadence can no longer starve the watchdog.

M2 (redundant blocking probe + dead snapshot helpers): the controller did
read() then a second blocking isAvailable() RS485 probe per tick — a transient
glitch on that probe after a good read could spuriously fail-safe-stop the pump.
Promoted snapshot() to the ISoilSensor interface (ModbusSoilSensor tracks read
history; LockedSoilSensor locks + delegates; MockSoilSensor implements it). tick()
now reads once then consumes ONE coherent snapshot() — no second probe, no torn
tuple; availability = ever-read-ok (never-read -> soil-unavailable, worked-then-
lost -> soil-stale). maybeLogData logs from the same snapshot. The snapshot
helpers are now live, not dead code, and the header/manual-flag comments match
the code.

Hardening: watering_task_start now logs a durable EventLogger::logFailsafe(
"watering-task-start-failed") on xTaskCreate failure (visible in /api/v1/events).

Test gaps (the deliverable is 100 % branch coverage): +T-A high-threshold soak
origin, +T-B reservoir auto-level-off suppresses fill (manual still works),
+T-C drop-sensitive 10-metric cap assertion (rejectedWrites==0), +NPK
phosphorus/potassium negative, +env-read-failure branch. branch-coverage.md
updated.

Docs: retagged stale TODO(PR-11) markers to PR-14 (diag_console env/level/power,
LockedPowerSensor); CLAUDE.md snapshot payload note qualified (error is soil-only).

Host 293/0; rev1 + rev2 build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cryptotomte cryptotomte merged commit b6d60bb into main Jul 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant