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
4 changes: 1 addition & 3 deletions .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
{
"feature_directory": "specs/004-modbus-soil-sensor"
}
{"feature_directory":"specs/005-bme280-i2c"}
25 changes: 25 additions & 0 deletions docs/parity-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,31 @@ each item below is the new contract behavior, host-tested in
PR-05 (reservoir board flag). The config store is extensible per-key so PR-05
can add them without a contract change.

### Deliberate divergences in the ESP-IDF port (feature 005, PR-03)

Intentional behavior changes from the Arduino BME280 driver (section 5), not
parity targets; each is the new contract behavior, host-tested in
`firmware/test_apps/host/main/test_bme280.cpp`.

- [ ] `[HOST]` **I2C address probing 0x76→0x77 with chip-identity check**:
legacy hard-codes address 0x77 and never verifies the chip ID; the port
probes both addresses, accepts only a device whose register 0xD0 reads 0x60
(a BMP280/foreign device is logged distinctly and rejected), and re-probes
BOTH addresses on recovery after loss — a module swapped to the other
address while unplugged is picked up transparently (spec 005 US3/FR-004).
- [ ] `[HOST]` **Last-good getter values after a failed read**: legacy left
NaN in the getters after a failed read; the port keeps the previous good
values and consumers gate on the read() result / getLastError() — aligned
with the soil-sensor contract (spec 005 FR-001/FR-007).
- [ ] `[HOST]` **`isAvailable()` is a live chip-ID probe**: legacy caches
"available" forever after the first successful init and can report a dead
sensor as alive; the port performs a real chip-ID read on every call, so
the unplug/replug HIL criterion is observable (spec 005 FR-009).
- [ ] `[HOST]` **Synchronized cross-task access via `LockedEnvironmentalSensor`**:
legacy has two unsynchronized readers on the same I2C device (main loop +
web server); the port serializes every interface call through the mutex
decorator — same pattern as the other Locked* wrappers (spec 005 FR-010).

## 7. WiFi / network / time

- [ ] `[HIL]` STA connect at boot using saved credentials: STA mode, auto-reconnect off (handled manually), WiFi modem sleep disabled, clean disconnect first, **60 s** connect timeout with LED toggling every 500 ms (`src/main.cpp:46`, `168-212`)
Expand Down
78 changes: 67 additions & 11 deletions firmware/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@ must stay green.
### Host tests (linux preview target)

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`):
store, data storage, the soil sensor decode/validation/calibration
(`test_soil_sensor.cpp`, real `ModbusSoilSensor` over `MockModbusClient`)
and the BME280 probe/calibration/compensation logic (`test_bme280.cpp`,
real `Bme280Sensor` over `MockI2cBus` — Bosch reference vectors, error
paths, address variants, sensor-task log policy, `MockEnvironmentalSensor`
consistency). The test executable's exit code equals the Unity failure
count (CI gate, job `host-test`):

```bash
cd firmware/test_apps/host
Expand All @@ -54,6 +57,7 @@ firmware/
├── main/
│ ├── app_main.cpp # Entry point — pumps forced OFF first, always
│ ├── diag_console.cpp/.h # esp_console UART REPL (prompt "ws>")
│ ├── sensor_task.cpp/.h # 5 s environmental poll task (feature 005)
│ ├── Kconfig.projbuild # Board revision choice
│ └── idf_component.yml # Pinned managed deps (esp-modbus, littlefs)
├── components/
Expand All @@ -62,17 +66,23 @@ firmware/
│ ├── interfaces/ # Header-only, NO IDF deps (host-includable)
│ │ └── include/interfaces/ # IActuator, IWaterPump, ITimeProvider,
│ │ # IConfigStore, IDataStorage,
│ │ # IModbusClient, ISoilSensor
│ │ # IModbusClient, ISoilSensor,
│ │ # IEnvironmentalSensor, II2cBus
│ ├── 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
│ ├── sensors/ # Soil sensor (feature 004) + BME280 (005)
│ │ ├── include/sensors/ # ModbusSoilSensor, Bme280Sensor (pure C++
│ │ │ # logic), EspModbusClient, EspI2cBus,
│ │ │ # LockedSoilSensor,
│ │ │ # LockedEnvironmentalSensor,
│ │ │ # SensorTaskLogPolicy, testing/
│ │ │ # (MockModbusClient, MockSoilSensor,
│ │ │ # MockI2cBus, MockEnvironmentalSensor)
│ │ └── src/ # EspModbusClient.cpp + esp-modbus dep and
│ │ # EspI2cBus.cpp + esp_driver_i2c dep
│ │ # excluded on linux target
│ └── storage/ # Config + data persistence (feature 003)
│ ├── include/storage/ # NvsConfigStore, LittleFsDataStorage (POSIX,
Expand All @@ -84,7 +94,8 @@ firmware/
└── test_apps/
└── host/ # Host test app (linux preview target, Unity):
# pump + config store + data storage +
# soil sensor (test_soil_sensor.cpp) suites
# soil sensor (test_soil_sensor.cpp) +
# BME280 (test_bme280.cpp) suites
```

Future components (drivers, controllers, web server) are added as siblings
Expand Down Expand Up @@ -135,6 +146,14 @@ rs485test # raw 1-register Modbus probe + statist
soil_cal_moisture | soil_cal_ph | soil_cal_ec <reference-value>
```

Feature 005 adds the environmental sensor command (HIL verification path,
one locked read; the failure hint distinguishes error 1 "sensor not found"
from error 2 "read failed" — SC-006):

```
env # one read(); T/RH/P with units or ERROR <code> (<hint>)
```

## Storage (config + data persistence)

Feature 003 (PR-06). Two redesigned, host-includable interfaces in
Expand Down Expand Up @@ -193,6 +212,43 @@ All pins and polarity/feature flags come from `board/board.h`
`BOARD_HAS_INA226`, `BOARD_NAME`). Never hard-code GPIO numbers elsewhere.
Board-conditional code uses `#if CONFIG_BOARD_REV2` / `#if BOARD_HAS_INA226`.

## BME280 environmental sensor (I2C)

Feature 005 (PR-03). Same architecture split as the soil sensor, at the
`II2cBus` interface: `Bme280Sensor` is pure C++ and holds ALL policy —
0x76→0x77 address probing with chip-ID verification (0xD0 == 0x60,
rejects e.g. a BMP280), calibration readout/parsing (incl. the
split-nibble dig_H4/H5), the Bosch datasheet reference compensation
(int32 T / int64 P / int32 H via t_fine, transcribed exactly — do not
"clean up"), unit conversion (°C/%RH/hPa), error codes 0/1/2, lazy
re-init and uninitialize-on-bus-error recovery (re-probes BOTH
addresses). It is host-tested against `MockI2cBus`, including Bosch
reference vectors. `EspI2cBus` is the only hardware touchpoint (the new
`driver/i2c_master.h` API — never the legacy `driver/i2c.h` — 100 kHz,
pins from `board/board.h`) and is excluded from the linux build.

**Shared bus (PR-05):** `app_main` owns the single `EspI2cBus` instance
(function-local static); PR-05's INA226 driver must receive the SAME
instance — never create a second bus on these pins. Bus-level transaction
safety comes from the i2c_master driver's bus lock; snapshot consistency
comes from `LockedEnvironmentalSensor`, the mandatory wrapper for all
cross-task access (sensor task + console REPL now; web PR-09, controller
PR-11).

**Sampling profile is parity** (like-for-like HIL comparison against the
Arduino unit): NORMAL mode, oversampling T×2 / P×16 / H×1, IIR ×16,
standby 500 ms — ctrl_hum written before ctrl_meas, then config.

The `sensor_task` (main/, 4096 B stack, priority 1, `vTaskDelayUntil`
5000 ms — parity parameters) polls the locked sensor, starts even when
the sensor is absent (lazy re-init recovers later) and never exits. Its
WARN/INFO/silence decisions live in the pure `SensorTaskLogPolicy`
(host-tested): WARN once on the valid→invalid transition and on recovery,
bounded repeats every 12th consecutive failure. Deliberate divergences
from the legacy driver (address probing, last-good getters, live
availability probe, locked access) are recorded in
`docs/parity-checklist.md` §6.

## Partition layout (4MB flash)

nvs (0x9000, 16K) | otadata (0xd000, 8K) | phy_init (0xf000, 4K) |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2026 Cryptotomte
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* @file IEnvironmentalSensor.h
* @brief Interface for the BME280 environmental sensor (T/RH/P).
*
* Ported from the frozen Arduino firmware
* (include/sensors/IEnvironmentalSensor.h) in the soil-sensor style: no
* ISensor base class, no getName() (research.md R5). Normative contract:
* specs/005-bme280-i2c/contracts/interfaces.md; registers, sampling profile
* and error codes: specs/005-bme280-i2c/data-model.md.
*
* Deliberate divergences from the legacy driver (parity-checklist §6):
* 0x76/0x77 address probing with a chip-identity check (legacy hard-codes
* 0x77, no identity check); last-good getter values after a failed read
* (legacy left NaN in the getters); isAvailable() as a real bus probe
* (legacy cached available-after-init forever).
*
* Validity contract (FR-001/FR-007): 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 (sensor task + console REPL) wrap them in the
* LockedEnvironmentalSensor decorator, same pattern as LockedSoilSensor.
*
* Part of the header-only `interfaces` component: no IDF includes allowed.
*/

#ifndef WATERINGSYSTEM_INTERFACES_IENVIRONMENTALSENSOR_H
#define WATERINGSYSTEM_INTERFACES_IENVIRONMENTALSENSOR_H

/**
* @brief Environmental sensor: atomic T/RH/P snapshots with lazy recovery.
*
* Error codes reported by getLastError()
* (specs/005-bme280-i2c/data-model.md):
*
* 0 OK — last operation succeeded
* 1 sensor not found — no device ACK on 0x76/0x77, or a responding
* device failed the chip-identity check
* 2 read failed — bus/communication error during a data read, a
* mid-initialization bus failure after a device identified
* (calibration readout / sampling-profile write), or the
* compensation produced NaN
*/
class IEnvironmentalSensor {
public:
virtual ~IEnvironmentalSensor() = default;

/**
* @brief Find and configure the sensor.
*
* Probes address 0x76 then 0x77, verifies the chip identity (register
* 0xD0 == 0x60), reads the calibration data and writes the parity
* sampling profile (ctrl_hum → ctrl_meas → config, data-model.md).
* Returns false with error 1 when no BME280 is found, and false with
* error 2 when a device identified but the calibration readout or
* sampling-profile write failed mid-initialization (the driver stays
* uninitialized, so the next attempt re-probes from scratch).
* Idempotent, and lazy-capable: read()/isAvailable() attempt
* initialization themselves when it has not happened yet — calling
* this first is recommended, not required (parity).
*/
virtual bool initialize() = 0;

/**
* @brief Take ONE atomic snapshot of temperature, humidity and pressure.
*
* Burst-reads the data registers 0xF7–0xFE in one bus transaction and
* compensates T → P → H (t_fine ordering). Atomic: either all three
* getters are refreshed, or the call fails (error 1 when the lazy
* (re-)initialization finds no sensor, error 2 on a bus error or NaN
* during the data read — a sensor unplugged mid-run reports error 2 on
* the failing read, then error 1 from the NEXT read's re-probe) and
* the last-good values remain untouched. A bus error marks the driver
* uninitialized, so the next call re-probes both addresses (recovery,
* FR-004). Exactly one bus attempt, no retry — recovery comes from the
* caller's poll cadence.
*
* @return true if a fully valid reading was taken.
*/
virtual bool read() = 0;

/**
* @brief Probe sensor presence with a REAL chip-ID read (FR-009).
*
* Every call performs an actual bus transaction — never cached state
* (deliberate divergence from the legacy cached availability). The
* probe itself does not modify getLastError(); when it triggers a lazy
* (re-)initialization, that initialize() owns its own error reporting —
* the ISoilSensor convention. Recovery from earlier failures is
* implicit: a sensor that identifies itself again is available again.
*
* Warning: after isAvailable() detects a loss, getLastError() may
* still be 0 (or stale) until the next read()/initialize() — consumers
* must not pair isAvailable() with getLastError().
*/
virtual bool isAvailable() = 0;

/**
* @brief Error code of the most recent initialize()/read() (0 = OK;
* table in the class comment). The isAvailable() probe never touches
* this code (its lazy-init path reports through initialize()).
*/
virtual int getLastError() = 0;

// Values from the most recent successful read(). NaN until the first
// successful read() (self-announcing placeholders), and after a failed
// read() they still hold the previous good reading — consumers gate on
// the read() result, never on the values.

/// Temperature in °C.
virtual float getTemperature() = 0;

/// Relative humidity in %RH.
virtual float getHumidity() = 0;

/// Barometric pressure in hPa (parity: legacy converts Pa → hPa).
virtual float getPressure() = 0;
};

#endif /* WATERINGSYSTEM_INTERFACES_IENVIRONMENTALSENSOR_H */
79 changes: 79 additions & 0 deletions firmware/components/interfaces/include/interfaces/II2cBus.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: 2026 Cryptotomte
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* @file II2cBus.h
* @brief Minimal register-oriented I2C master interface (hardware seam).
*
* The host-test seam for I2C sensor drivers (research.md R6): Bme280Sensor
* holds all policy above this interface and is host-tested against
* MockI2cBus; EspI2cBus is the only hardware-touching implementation.
* Normative contract: specs/005-bme280-i2c/contracts/interfaces.md.
*
* This interface carries no BME280 knowledge — PR-05's INA226 driver reuses
* it on the same bus instance (INA226 uses 16-bit register values; PR-05
* may extend the interface or compose two 8-bit operations, its call).
*
* Part of the header-only `interfaces` component: no IDF includes allowed.
*/

#ifndef WATERINGSYSTEM_INTERFACES_II2CBUS_H
#define WATERINGSYSTEM_INTERFACES_II2CBUS_H

#include <cstddef>
#include <cstdint>

/**
* @brief I2C master: probe + 8-bit register reads/writes, one transaction
* per call.
*
* All addresses are 7-bit. Every method returns false on NACK, bus error
* or timeout; there are NO retries at this layer — recovery policy belongs
* to the sensor driver above. Implementations serialize safely for
* multi-task use at transaction granularity (the ESP implementation
* inherits this from the i2c_master driver's bus lock, research.md R3;
* mocks are used single-threaded in host tests).
*/
class II2cBus {
public:
virtual ~II2cBus() = default;

/**
* @brief Check whether a device ACKs at @p address7.
*
* @param address7 7-bit device address.
* @return true if the device acknowledged its address.
*/
virtual bool probe(uint8_t address7) = 0;

/**
* @brief Read @p len consecutive registers starting at @p startReg.
*
* One transaction: register-pointer write followed by an N-byte read
* with a REPEATED START in between — required for correct BME280 burst
* reads (the chip's register shadowing guarantees a consistent
* measurement only within a single burst transaction).
*
* @param address7 7-bit device address.
* @param startReg First register address.
* @param buf Caller-owned buffer of at least @p len bytes; contents are
* defined only when the call returns true.
* @param len Number of registers (bytes) to read.
* @return true if all requested bytes were read.
*/
virtual bool readRegisters(uint8_t address7, uint8_t startReg,
uint8_t* buf, size_t len) = 0;

/**
* @brief Write one byte to one register (register pointer + value in a
* single transaction).
*
* @param address7 7-bit device address.
* @param reg Register address.
* @param value Value to write.
* @return true if the device acknowledged the write.
*/
virtual bool writeRegister(uint8_t address7, uint8_t reg,
uint8_t value) = 0;
};

#endif /* WATERINGSYSTEM_INTERFACES_II2CBUS_H */
Loading
Loading