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
37 changes: 37 additions & 0 deletions firmware/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,43 @@ code. **Hardware validation is deferred to PR-14** (no INA226 on the rev1
rig): the driver is host-verified only, and the written config value
carries a `TODO(PR-14)` bench confirmation.

## WiFi provisioning & station management

Feature 007 (PR-07). **FR9 decision: a custom SoftAP provisioning portal** (AP
at 192.168.4.1, WPA2, a minimal self-contained setup page + one POST handler on
a standalone `esp_http_server`), chosen over IDF `wifi_provisioning` — full spec
and rationale in `specs/007-wifi-provisioning/` (`spec.md` + research decision
**D1**; `wifi_provisioning` is the documented re-escalation fallback if the
portal proves unreliable at HIL, research R1). The `network` component splits at
the `IWifiDriver` seam, same pattern as the sensor drivers:

- **Pure, host-tested** (`components/network/include/network/`): the
`WifiManager` state machine (Provisioning / Connecting / Connected /
Reconnecting / ReconnectPaused, parity reconnect cadence — 10 s retry, +60 s
pause after 5 consecutive failures, 5 s health monitor; **never reboots** —
FR-013 no boot loop), `validateWifiCredentials` (SSID 1–32, password
empty-or-8..64), `decideBootMode` + `shouldClearCredentialsOnBoot`
(`WifiBootMode.h`). Driven over `MockWifiDriver` + `FakeTimeProvider` in
`test_apps/host/main/test_wifi.cpp`.
- **Hardware touchpoints** (target-only, excluded from the linux build):
`EspWifiDriver` (STA + AP netifs, `esp_event` → `WifiEvent` queue) and
`ProvisioningPortal` (`esp_http_server`).

**Isolation (FR-014):** `WifiManager`'s constructor takes only
`IWifiDriver&`/`IConfigStore&`/`ITimeProvider&`/`ReconnectPolicy` — no
watering/pump/sensor reference — and the wifi task is a SEPARATE FreeRTOS task
from the 10 Hz pump/level loop with no shared mutex; WiFi outages never touch
watering. Credentials come from PR-06's `IConfigStore` (never logged, FR-004).

**Boot flow in `app_main`** (all strictly after `pumps_force_off()`): read the
config button (`BOARD_PIN_BTN_CONFIG`, GPIO18, active LOW, >= 5 s hold, 100 ms
LED blink) → `decideBootMode` → provisioning (button-forced on a configured
device clears credentials first, per the data-model boot rule) or station
(`begin(Station)` + `wifi_task_start`). Kconfig: `WS_PROV_AP_SSID`,
`WS_PROV_AP_PASSWORD`, `WS_WIFI_*` reconnect constants. LED scope (parity
§7/§9): 500 ms connect-attempt toggle (wifi task) + 100 ms config-button-hold
blink (app_main); HIL checklist in `specs/007-wifi-provisioning/checklists/hil.md`.

## Partition layout (4MB flash)

nvs (0x9000, 16K) | otadata (0xd000, 8K) | phy_init (0xf000, 4K) |
Expand Down
95 changes: 95 additions & 0 deletions firmware/components/interfaces/include/interfaces/IWifiDriver.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// SPDX-FileCopyrightText: 2026 Cryptotomte
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* @file IWifiDriver.h
* @brief WiFi hardware seam (STA/AP control + non-blocking event poll).
*
* The host-test seam for WiFi (feature 007): the pure WifiManager holds all
* timing and state-machine logic above this interface and is host-tested
* against MockWifiDriver; EspWifiDriver is the only hardware-touching
* implementation (esp_wifi/esp_netif/esp_event) and is excluded from the
* linux build. Mirrors IModbusClient / II2cBus. Normative contract:
* specs/007-wifi-provisioning/contracts/IWifiDriver.md.
*
* Part of the header-only `interfaces` component: no IDF includes allowed.
*/

#ifndef WATERINGSYSTEM_INTERFACES_IWIFIDRIVER_H
#define WATERINGSYSTEM_INTERFACES_IWIFIDRIVER_H

#include <cstdint>
#include <string>

/**
* @brief WiFi lifecycle events, drained one per tick via pollEvent().
*
* `Connected` = association succeeded (L2). `GotIp` = DHCP lease acquired —
* the "usable" signal the manager treats as fully connected.
* `Disconnected` / `ConnectFailed` = drop / association failure (the manager
* treats both as "attempt failed"). `None` = the event queue is empty.
*
* Event ordering (contract): a successful connect delivers `Connected` then
* `GotIp`; a failure delivers `ConnectFailed` or `Disconnected`.
*/
enum class WifiEvent { None, Connected, GotIp, Disconnected, ConnectFailed };

/**
* @brief STA/AP control plus a non-blocking event queue.
*
* Behavioral contract: no method blocks on network I/O — the outcome of a
* connect/AP-start attempt arrives later as an event via pollEvent(). This
* keeps the pure WifiManager deterministic and lets the wifi task never
* stall (FR-014). The driver owns no timers (all cadence lives in
* WifiManager via ITimeProvider) and touches only WiFi/netif/event-loop
* resources — never pump/sensor state.
*/
class IWifiDriver {
public:
virtual ~IWifiDriver() = default;

/**
* @brief Configure STA and begin association (non-blocking).
*
* @param ssid Network SSID to join.
* @param password Network password (empty for an open network).
* @return false only on a synchronous config error; success/failure of
* the attempt itself arrives later as a WifiEvent.
*/
virtual bool staConnect(const std::string& ssid,
const std::string& password) = 0;

/**
* @brief Stop STA / disconnect. Idempotent.
*/
virtual void staStop() = 0;

/**
* @brief Start the SoftAP (WPA2) at 192.168.4.1 (non-blocking).
*
* @param ssid AP SSID to advertise.
* @param password AP password (WPA2).
* @return false only on a synchronous config error.
*/
virtual bool apStart(const std::string& ssid,
const std::string& password) = 0;

/**
* @brief Stop the SoftAP. Idempotent.
*/
virtual void apStop() = 0;

/**
* @brief Drain the next queued event, or None if the queue is empty.
*
* Called once per manager tick; thread-safe (the driver enqueues from
* the esp_event callback).
*/
virtual WifiEvent pollEvent() = 0;

/**
* @brief Last known RSSI in dBm; unspecified when not connected.
*/
virtual int8_t rssi() const = 0;
};

#endif /* WATERINGSYSTEM_INTERFACES_IWIFIDRIVER_H */
39 changes: 39 additions & 0 deletions firmware/components/network/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# network — WiFi station management + first-boot SoftAP provisioning.
#
# Same split as sensors/actuators/storage: all timing and state-machine
# logic lives in pure C++ (WifiManager, decideBootMode, credential
# validation) driven by ITimeProvider + the IWifiDriver seam, so it builds
# and is unit-tested on the linux preview target. The IDF-touching classes
# (EspWifiDriver over esp_wifi/esp_netif/esp_event, ProvisioningPortal over
# esp_http_server) carry no business logic and are excluded from the linux
# build (research.md, same mechanism as the other components).
#
# US1 (feature 007): the pure credential-validation and boot-mode logic is
# header-only (WifiCredentialValidation.h, WifiBootMode.h — no .cpp). US2 adds
# the pure WifiManager state machine (WifiManager.cpp) — a pure source that
# goes to SRCS on BOTH branches (host tests + target). The provisioning HTTP
# portal (ProvisioningPortal.cpp) and EspWifiDriver.cpp are hardware
# touchpoints and stay target-only.
if(${IDF_TARGET} STREQUAL "linux")
# Host build: pure sources only (validation + boot-mode are header-only;
# WifiManager is the pure state machine, host-tested over MockWifiDriver).
idf_component_register(
SRCS "src/WifiManager.cpp"
INCLUDE_DIRS "include"
REQUIRES interfaces board
)
else()
# Target build: pure sources + the hardware touchpoints. The WiFi/HTTP-
# server IDF deps are private — they appear only in the EspWifiDriver /
# ProvisioningPortal .cpp files, never in this component's public headers
# (same rule as storage's littlefs and sensors' esp-modbus): the portal
# header holds the server handle as an opaque pointer.
idf_component_register(
SRCS "src/WifiManager.cpp"
"src/ProvisioningPortal.cpp"
"src/EspWifiDriver.cpp"
INCLUDE_DIRS "include"
REQUIRES interfaces board
PRIV_REQUIRES esp_wifi esp_netif esp_event esp_http_server
)
endif()
83 changes: 83 additions & 0 deletions firmware/components/network/include/network/EspWifiDriver.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// SPDX-FileCopyrightText: 2026 Cryptotomte
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* @file EspWifiDriver.h
* @brief IWifiDriver implementation over esp_wifi/esp_netif/esp_event
* (target-only, feature 007 US2).
*
* The single hardware touchpoint of the WiFi subsystem: it owns the STA and
* AP netifs, drives esp_wifi (mode/config/start/connect) and translates the
* esp_event callbacks into a thread-safe WifiEvent queue drained by
* pollEvent(). It carries NO timing and NO business logic — all cadence and
* state-machine decisions live in the pure WifiManager above the IWifiDriver
* seam (contracts/IWifiDriver.md). Excluded from the linux build (esp_wifi
* has no host port; host tests use MockWifiDriver).
*
* PRIV rule (same as EspI2cBus / StorageMount / ProvisioningPortal): the
* heavy IDF headers (esp_wifi.h, esp_netif.h, esp_event.h, freertos/queue.h)
* appear only in the .cpp. The netif handles and the FreeRTOS queue handle are
* held here as opaque void* so a consumer that includes this header (only
* app_main, on target) pulls in no WiFi/netif dependency. Credential values
* are never logged (FR-004 / PR-06 convention): the SSID may be logged, the
* password never.
*/

#ifndef WATERINGSYSTEM_NETWORK_ESPWIFIDRIVER_H
#define WATERINGSYSTEM_NETWORK_ESPWIFIDRIVER_H

#include <string>

#include "esp_err.h"

#include "interfaces/IWifiDriver.h"

/**
* @brief esp_wifi-backed WiFi driver (STA/AP control + non-blocking events).
*
* Lifetime: construct once at the wiring site (app_main function-local static,
* so no non-trivial constructor runs before the boot pump fail-safe — the
* constructor is trivial and all IDF work happens in init()). init() must be
* called exactly once, after esp_netif_init()/esp_event_loop_create_default()
* and NVS init already done in app_main, before the first staConnect/apStart.
*/
class EspWifiDriver : public IWifiDriver {
public:
EspWifiDriver() = default;
~EspWifiDriver() override;

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

/**
* @brief One-time init: create the default STA + AP netifs, esp_wifi_init,
* register the WIFI_EVENT / IP_EVENT handlers and create the event
* queue. Idempotent (a second call is a successful no-op).
*
* @return ESP_OK on success; the first failing esp_err_t otherwise. On a
* failure the driver stays uninitialized and every control method
* becomes a logged no-op / false — WiFi is simply unavailable and
* the watering path is unaffected (FR-014).
*/
esp_err_t init();

bool staConnect(const std::string& ssid,
const std::string& password) override;
void staStop() override;
bool apStart(const std::string& ssid,
const std::string& password) override;
void apStop() override;
WifiEvent pollEvent() override;
int8_t rssi() const override;

private:
// Held opaque to keep esp_wifi/esp_netif/freertos headers in the .cpp.
void* eventQueue_ = nullptr; ///< QueueHandle_t of WifiEvent (thread-safe)
void* staNetif_ = nullptr; ///< esp_netif_t* for the STA interface
void* apNetif_ = nullptr; ///< esp_netif_t* for the AP interface
void* wifiHandlerInstance_ = nullptr; ///< esp_event_handler_instance_t (WIFI_EVENT)
void* ipHandlerInstance_ = nullptr; ///< esp_event_handler_instance_t (IP_EVENT)
bool initialized_ = false; ///< true once init() succeeded
bool started_ = false; ///< true between esp_wifi_start and stop
};

#endif /* WATERINGSYSTEM_NETWORK_ESPWIFIDRIVER_H */
110 changes: 110 additions & 0 deletions firmware/components/network/include/network/ProvisioningPortal.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// SPDX-FileCopyrightText: 2026 Cryptotomte
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* @file ProvisioningPortal.h
* @brief First-boot SoftAP setup portal (target-only HTTP server).
*
* A minimal standalone esp_http_server that runs while the device is in
* Provisioning (AP) mode (feature 007, US1). It serves a small self-contained
* setup page and accepts a POST that validates + persists WiFi credentials,
* then schedules a restart so the operator's new settings take effect. The
* full JSON status API and /api/wifi/scan are out of scope (PR-09).
*
* This is the only HTTP touchpoint of the `network` component and is excluded
* from the linux build (esp_http_server has no host port); all reusable
* policy (credential validation) lives in the pure WifiCredentialValidation
* header so it stays host-tested. Normative contract:
* specs/007-wifi-provisioning/contracts/provisioning-portal.md.
*
* PRIV rule (same as EspI2cBus / StorageMount): esp_http_server.h appears
* only in the .cpp; the server handle is held as an opaque pointer and the
* HTTP route handlers are file-local in the .cpp — so consumers (app_main)
* that include this header pull in no HTTP-server dependency. Credential
* values are never logged or echoed back (FR-004 / PR-06 convention).
*/

#ifndef WATERINGSYSTEM_NETWORK_PROVISIONINGPORTAL_H
#define WATERINGSYSTEM_NETWORK_PROVISIONINGPORTAL_H

#include <cstdint>
#include <functional>
#include <string>

#include "esp_err.h"

#include "interfaces/IConfigStore.h"

/**
* @brief Standalone provisioning HTTP server over IConfigStore.
*
* Lifetime: construct once at the wiring site (app_main), call start() when
* entering provisioning mode and keep the instance alive for as long as the
* portal serves. The config store is held by reference and outlives the
* portal (it is an app_main function-local static).
*/
class ProvisioningPortal {
public:
/// Delay between the credential-save HTTP response and the restart, so
/// the response reaches the browser before the device reboots (FR-007).
static constexpr uint32_t kRestartDelayMs = 3000;

/// Hook invoked to trigger the (deferred) restart after a successful
/// credential save. Kept injectable so esp_restart stays out of the
/// directly-called handler path; the default schedules it kRestartDelayMs
/// later on a short-lived task.
using RestartHook = std::function<void()>;

/**
* @brief Construct the portal over a config store and a restart hook.
*
* @param configStore Credential sink; must outlive the portal.
* @param restartHook Called once on a successful save. When null (the
* default), a built-in scheduled restart is used.
*/
explicit ProvisioningPortal(IConfigStore& configStore,
RestartHook restartHook = nullptr);

~ProvisioningPortal();

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

/**
* @brief Start the HTTP server and register the routes.
*
* @return ESP_OK on success; an esp_err_t on server start / route
* registration failure (already-started is a successful no-op).
*/
esp_err_t start();

/**
* @brief Stop the HTTP server. Idempotent.
*/
void stop();

// -- Submission API used by the POST route handler (.cpp) --------------
// Validation happens in the handler via the pure validateWifiCredentials;
// these two steps stay on the instance so the handler can interleave them
// with the HTTP response (persist, respond 200, then restart).

/**
* @brief Persist already-validated credentials.
* @return false on a persistence failure (handler responds 5xx and the
* device stays provisionable).
*/
bool persistCredentials(const std::string& ssid,
const std::string& password);

/**
* @brief Invoke the restart hook (default: schedule a deferred reboot).
* Called after the success response has been sent.
*/
void scheduleRestart();

private:
IConfigStore& configStore_;
RestartHook restartHook_;
void* server_ = nullptr; ///< opaque httpd_handle_t (see .cpp)
};

#endif /* WATERINGSYSTEM_NETWORK_PROVISIONINGPORTAL_H */
Loading
Loading