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
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"feature_directory": "specs/003-nvs-littlefs-storage"
"feature_directory": "specs/004-modbus-soil-sensor"
}
46 changes: 40 additions & 6 deletions firmware/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ 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
Logic is unit-tested natively, no ESP32 needed: pump enforcement, config
store, data storage and the soil sensor decode/validation/calibration
(`test_soil_sensor.cpp`, real `ModbusSoilSensor` over `MockModbusClient`).
The test executable's exit code equals the Unity failure count (CI gate, job
`host-test`):

```bash
Expand Down Expand Up @@ -59,12 +61,19 @@ firmware/
│ │ └── include/board/board.h
│ ├── interfaces/ # Header-only, NO IDF deps (host-includable)
│ │ └── include/interfaces/ # IActuator, IWaterPump, ITimeProvider,
│ │ # IConfigStore, IDataStorage
│ │ # IConfigStore, IDataStorage,
│ │ # IModbusClient, ISoilSensor
│ ├── actuators/ # Pump drivers
│ │ ├── include/actuators/ # WaterPump (pure C++ logic), GpioWaterPump,
│ │ │ # EspTimeProvider (esp32-only header),
│ │ │ # testing/ (MockWaterPump, FakeTimeProvider)
│ │ └── src/ # GpioWaterPump.cpp excluded on linux target
│ ├── sensors/ # RS485 Modbus soil sensor (feature 004)
│ │ ├── include/sensors/ # ModbusSoilSensor (pure C++ logic),
│ │ │ # EspModbusClient, LockedSoilSensor,
│ │ │ # testing/ (MockModbusClient, MockSoilSensor)
│ │ └── src/ # EspModbusClient.cpp + esp-modbus dep
│ │ # excluded on linux target
│ └── storage/ # Config + data persistence (feature 003)
│ ├── include/storage/ # NvsConfigStore, LittleFsDataStorage (POSIX,
│ │ # host-runnable), StorageMount (esp32-only),
Expand All @@ -74,7 +83,8 @@ firmware/
│ # on linux target (esp_littlefs has no port)
└── test_apps/
└── host/ # Host test app (linux preview target, Unity):
# pump + config store + data storage suites
# pump + config store + data storage +
# soil sensor (test_soil_sensor.cpp) suites
```

Future components (drivers, controllers, web server) are added as siblings
Expand Down Expand Up @@ -115,6 +125,16 @@ config get | set <item> <value> | wifi <ssid> <password> | wifi-clear | factory-
storage stats | log <metric> <value> | query <metric> [t0 t1] | event <category> <detail> | events [n]
```

Feature 004 adds the soil sensor commands (HIL verification path, same
thin-wrapper rule; a failed calibration-register write is reported as
non-fatal — legacy parity):

```
soil # one read(); 7 values or error code
rs485test # raw 1-register Modbus probe + statistics
soil_cal_moisture | soil_cal_ph | soil_cal_ec <reference-value>
```

## Storage (config + data persistence)

Feature 003 (PR-06). Two redesigned, host-includable interfaces in
Expand Down Expand Up @@ -146,7 +166,19 @@ data is migrated; on-disk formats diverge from legacy by design — see
The diagnostic console (`ws>`) exposes `config` and `storage` subcommands for
the HIL verification path (see below).

## Board abstraction
## Soil sensor (RS485 Modbus)

Feature 004 (PR-08). `components/sensors/` splits the driver at the
`IModbusClient` interface: `ModbusSoilSensor` is pure C++
(decode/scaling/validation/calibration, host-tested against
`MockModbusClient`), `EspModbusClient` is the only hardware touchpoint
(esp-modbus RTU master, UART RS485 half-duplex on both boards, RX pull-up
for the rev2 SHDN̅/hi-Z case) and is excluded from the linux build. The
**esp-modbus dependency is pinned `==2.1.2`** in the component's
`idf_component.yml` and rule-gated to `target != linux`, keeping it out of
the host-test dependency graph. Cross-task access goes through
`LockedSoilSensor` (console REPL now, main-loop reader in PR-11) — same
pattern as the other Locked* wrappers.

Two board revisions exist, selected via Kconfig (`main/Kconfig.projbuild`):

Expand Down Expand Up @@ -189,7 +221,9 @@ must fit in 1.5MB per slot.
logging. Check IDF errors with `ESP_ERROR_CHECK` or explicit handling.
- **Include guards:** `WATERINGSYSTEM_PATH_FILE_H`
(e.g. `WATERINGSYSTEM_BOARD_BOARD_H`).
- **Managed dependencies** are pinned exactly in `main/idf_component.yml`;
- **Managed dependencies** are pinned exactly in `main/idf_component.yml`
AND `components/sensors/idf_component.yml` (esp-modbus is pinned `==2.1.2`
in both — bump the two in lockstep or the resolver conflicts);
`dependencies.lock` is committed, `managed_components/` is not.

## Testing strategy
Expand Down
7 changes: 7 additions & 0 deletions firmware/components/board/include/board/board.h
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
#define BOARD_PIN_RS485_RX 17
#define BOARD_HAS_RS485_DE 1
#define BOARD_PIN_RS485_DE 25
/* Modbus RTU runs on UART2 at 9600 baud 8N1 (parity: legacy Serial2,
* docs/parity-checklist.md §5). */
#define BOARD_RS485_UART_PORT 2

/* Pumps (MOSFET gates, active high) */
#define BOARD_PIN_MAIN_PUMP 26
Expand Down Expand Up @@ -83,6 +86,10 @@
* is 0: any reference that is not guarded by #if BOARD_HAS_RS485_DE becomes
* a compile error instead of undefined behavior (e.g. 1ULL << -1) or a
* silently dropped ESP_ERR_INVALID_ARG at runtime. */
/* Modbus RTU runs on UART2 at 9600 baud 8N1 (parity: legacy Serial2,
* docs/parity-checklist.md §5). The UART number is a parity fact, not part
* of the provisional rev2 pin map — no TODO(SYNC1). */
#define BOARD_RS485_UART_PORT 2

/* Pumps (MOSFET gates, active high) */
#define BOARD_PIN_MAIN_PUMP 26 // TODO(SYNC1): final rev2 pin map frozen at hardware sync 1
Expand Down
116 changes: 116 additions & 0 deletions firmware/components/interfaces/include/interfaces/IModbusClient.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// SPDX-FileCopyrightText: 2026 Cryptotomte
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* @file IModbusClient.h
* @brief Modbus RTU master interface for the RS485 sensor bus.
*
* Ported from the frozen Arduino firmware (include/communication/
* IModbusClient.h) with the same method surface; Arduino-era base-class
* baggage is trimmed. Normative contract:
* specs/004-modbus-soil-sensor/contracts/interfaces.md.
*
* Error codes reported by getLastError()
* (specs/004-modbus-soil-sensor/data-model.md):
*
* 0 OK
* 1 not initialized
* 2 bus/communication error (CRC, framing, truncated response; also
* covers slave exceptions collapsed by implementations that cannot
* surface the exception number — see 100+n)
* 3 timeout (no response within the configured timeout)
* 5 range validation failed — set by the sensor layer on top of this
* interface, never by IModbusClient implementations themselves
* 100+n Modbus slave exception n (when the implementation can surface it
* — EspModbusClient collapses slave exceptions onto code 2,
* research.md R6; only test mocks emit 100+n today; consumers must
* not branch on 100+n)
*
* Part of the header-only `interfaces` component: no IDF includes allowed.
*/

#ifndef WATERINGSYSTEM_INTERFACES_IMODBUSCLIENT_H
#define WATERINGSYSTEM_INTERFACES_IMODBUSCLIENT_H

#include <cstdint>

/**
* @brief Modbus RTU master: single-attempt register reads/writes.
*/
class IModbusClient {
public:
virtual ~IModbusClient() = default;

/**
* @brief Bring up the bus (UART/transceiver). Must precede any transfer.
*
* @return true on success; false leaves the client unusable (subsequent
* transfers fail with error 1).
*/
virtual bool initialize() = 0;

/**
* @brief Read holding registers (Modbus function 0x03).
*
* Performs exactly ONE bus attempt — no internal retry (parity,
* docs/parity-checklist.md §5); recovery comes from the caller's read
* cadence. On failure returns false and getLastError() carries the code.
*
* @param deviceAddress Modbus slave address.
* @param startRegister First holding register address.
* @param count Number of consecutive registers to read.
* @param buffer Caller-owned array of at least `count` elements; filled
* only on success.
* @return true if all requested registers were read.
*/
virtual bool readHoldingRegisters(uint8_t deviceAddress, uint16_t startRegister,
uint16_t count, uint16_t* buffer) = 0;

/**
* @brief Write a single holding register (Modbus function 0x06).
*
* Exactly one bus attempt, no retry (parity). Success means the
* addressed slave returned a well-formed FC06 response (address,
* function and CRC validated) — a sent-but-not-acknowledged write
* reports false. Implementations are NOT required to compare the echoed
* register/value byte-for-byte against the request (the legacy client
* did; see the parity-divergence note in EspModbusClient.cpp).
*
* @param deviceAddress Modbus slave address.
* @param registerAddress Holding register address.
* @param value Value to write.
* @return true if the slave acknowledged the write with a well-formed
* response.
*/
virtual bool writeSingleRegister(uint8_t deviceAddress, uint16_t registerAddress,
uint16_t value) = 0;

/**
* @brief Error code of the most recent operation (0 = OK; table above).
*/
virtual int getLastError() = 0;

/**
* @brief Set the response timeout for subsequent transfers.
*
* Parity default is 3000 ms (docs/parity-checklist.md §5).
* Implementations may apply the value at initialize() time only
* (EspModbusClient does — research.md R5).
*
* @param timeoutMs Timeout in milliseconds.
*/
virtual void setTimeout(uint32_t timeoutMs) = 0;

/**
* @brief Cumulative transaction statistics.
*
* Every readHoldingRegisters()/writeSingleRegister() call increments
* exactly one of the two counters; reading the statistics increments
* neither.
*
* @param successCount Out: number of successful transfers.
* @param errorCount Out: number of failed transfers.
*/
virtual void getStatistics(uint32_t* successCount, uint32_t* errorCount) = 0;
};

#endif /* WATERINGSYSTEM_INTERFACES_IMODBUSCLIENT_H */
135 changes: 135 additions & 0 deletions firmware/components/interfaces/include/interfaces/ISoilSensor.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// SPDX-FileCopyrightText: 2026 Cryptotomte
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* @file ISoilSensor.h
* @brief Interface for the RS485 Modbus soil sensor (7 quantities).
*
* Ported from the frozen Arduino firmware (include/sensors/ISoilSensor.h)
* with one deliberate trim: legacy setValidRange/isWithinValidRange are NOT
* ported — validation ranges are the fixed parity constants (moisture
* 0–100 %, temperature −40–80 °C, pH 3–9) and no caller in the legacy
* firmware ever changed them at runtime. Normative contract:
* specs/004-modbus-soil-sensor/contracts/interfaces.md; register map,
* scaling and error codes: specs/004-modbus-soil-sensor/data-model.md.
*
* Validity contract (FR-005): a false return from read() means the data is
* invalid. The getters keep returning the last-good values, so consumers
* MUST gate on the read result / getLastError() — never on value
* plausibility.
*
* Concurrency: implementations are unsynchronized by design; cross-task
* consumers (main loop + console REPL) wrap them in the LockedSoilSensor
* decorator, same pattern as LockedWaterPump/LockedConfigStore.
*
* Part of the header-only `interfaces` component: no IDF includes allowed.
*/

#ifndef WATERINGSYSTEM_INTERFACES_ISOILSENSOR_H
#define WATERINGSYSTEM_INTERFACES_ISOILSENSOR_H

/**
* @brief Soil sensor: atomic multi-quantity reads with parity validation.
*/
class ISoilSensor {
public:
virtual ~ISoilSensor() = default;

/**
* @brief Prepare the sensor (brings up the underlying Modbus client if
* needed). Implementations may initialize lazily — read()/isAvailable()/
* calibrate*() attempt initialization themselves when it has not
* happened yet — so calling this first is recommended, not required.
*/
virtual bool initialize() = 0;

/**
* @brief Read all quantities in ONE 9-register transaction
* (registers 0x0000–0x0008), single bus attempt, no retry.
*
* The reading is atomic: either every value is decoded, scaled and
* range-validated and the getters are refreshed, or the call fails and
* the last-good getter values remain untouched. false means the data is
* INVALID (bus error, timeout, exception or range validation failure —
* see getLastError()); consumers gate on this result, never on how
* plausible the getter values look.
*
* @return true if a fully valid reading was taken.
*/
virtual bool read() = 0;

/**
* @brief Probe sensor presence with a REAL 1-register bus read (parity).
*
* Every call performs an actual bus transaction — never a cached or
* derived state. Recovery from earlier failures is implicit: a sensor
* that responds again is available again.
*/
virtual bool isAvailable() = 0;

/**
* @brief Error code of the most recent read()/initialize()/calibrate*()
* (0 = OK).
*
* isAvailable() never touches this code. Note that a calibrate*()
* returning true may still leave a nonzero code from the non-fatal
* calibration-register write (see the calibration block below).
*
* Same table as IModbusClient; code 5 (range validation failed) is
* produced by this layer, never by the client. See
* specs/004-modbus-soil-sensor/data-model.md.
*/
virtual int getLastError() = 0;

// Values from the most recent successful read(). Before the FIRST
// successful read() they are meaningless placeholders, and after a
// failed read() they still hold the previous good reading — consumers
// gate on the read() result, never on the values.

/// Soil moisture in percent (0–100).
virtual float getMoisture() = 0;

/// Soil temperature in °C, signed (−40–80).
virtual float getTemperature() = 0;

/**
* @brief Humidity in percent — identical to getMoisture() (parity).
*
* The sensor reports a single moisture/humidity quantity in register
* 0x0000; the legacy firmware exposed it under both names.
*/
virtual float getHumidity() = 0;

/// Soil pH (validated 3–9).
virtual float getPH() = 0;

/// Electrical conductivity in µS/cm (not range-enforced, parity).
virtual float getEC() = 0;

/// Nitrogen content in mg/kg (not range-enforced, parity).
virtual float getNitrogen() = 0;

/// Phosphorus content in mg/kg (not range-enforced, parity).
virtual float getPhosphorus() = 0;

/// Potassium content in mg/kg (not range-enforced, parity).
virtual float getPotassium() = 0;

// Calibration (CP1 decision A — legacy semantics). Each call computes a
// local correction factor (reference / current raw reading), applies it
// to every subsequent read, and best-effort writes the factor to the
// sensor's calibration register (0x0100/0x0101/0x0102) — a failed
// sensor-register write is NON-FATAL and does not fail the call: the
// call returns true while getLastError() reports the write error.
// Factors are RAM-only for now; persistence wired in PR-09/PR-11.

/// Calibrate moisture against a reference value in percent.
virtual bool calibrateMoisture(float referenceValue) = 0;

/// Calibrate pH against a reference value.
virtual bool calibratePH(float referenceValue) = 0;

/// Calibrate EC against a reference value in µS/cm.
virtual bool calibrateEC(float referenceValue) = 0;
};

#endif /* WATERINGSYSTEM_INTERFACES_ISOILSENSOR_H */
Loading
Loading