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
53 changes: 53 additions & 0 deletions firmware/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,59 @@ device clears credentials first, per the data-model boot rule) or station
§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`.

## SNTP time, task watchdog & event logging (feature 008)

Feature 008 (PR-08) adds two small components plus target-side glue in `main/`.

**`time` component** — pure `TimeService` (epoch plausibility + Swedish
local-time formatting via `<ctime>`, host-tested), the `IWallClock` target
implementation `SystemWallClock` (`time(nullptr)`) and the `SntpClient` starter
(`esp_netif_sntp` against `CONFIG_WS_SNTP_SERVER`, TZ `CET-1CEST,M3.5.0,M10.5.0/3`).
`SntpClient.cpp` is the only genuine IDF touchpoint; `SystemWallClock.cpp` is
pure POSIX (`time(nullptr)`), kept target-side by convention (the host injects
`FakeWallClock`). Both are excluded from the linux build (same mechanism as
storage/sensors/network). `app_main`
constructs one `SntpClient`, calls `applyTimezone()` once at init, and the
`SystemObserver` starts the SNTP service **once, on the first
`WifiState::Connected` transition** (SNTP needs an IP) — never in
provisioning/headless mode. `start()` is idempotent and non-fatal: an
unreachable server is retried by the SNTP service, never a boot/watering
failure. The diag console `time` command reads `SystemWallClock` + the
`SntpClient`'s `SyncStatus`.

**Time-not-set contract (for PR-11):** until the first successful sync the wall
clock is implausible (`isPlausibleEpoch(0)` is false); consumers MUST treat a
not-set clock as "no timestamp" rather than 1970. The event log records events
regardless (with whatever the clock reports); PR-11's scheduler must gate any
time-of-day watering decision on a plausible clock.

**`events` component** — the pure, host-tested `EventLogger` (categories
`reset`/`wifi`/`pump`; producers `logReset`/`logWifi`/`logPumpStart`/`logPumpStop`;
a failed store increments a dropped counter, never throws — logging never blocks
or crashes watering, FR-014). It writes through the shared `LockedDataStorage`.
The target-side `SystemObserver` (`main/system_observer.*`) edge-detects WiFi
state changes and pump start/stop from the 10 Hz loop and forwards them. **Reset
reason:** at boot, exactly once, `app_main` calls `esp_reset_reason()` →
`event_logger.logReset(...)` (before `watchdog_init()`), so a prior watchdog
reboot appears in `storage events` as `reset=TASK_WDT`. The pump fail-safe still
runs first (top of `app_main`); this only observes the cause.

**Task watchdog** (`main/task_watchdog.*`, thin wrappers over `esp_task_wdt`).
`CONFIG_ESP_TASK_WDT_INIT=y` + `CONFIG_ESP_TASK_WDT_PANIC=y` (sdkconfig.defaults)
init the WDT at boot; `watchdog_init()` **reconfigures** it to
`CONFIG_WS_TASK_WDT_TIMEOUT_S` (default 20 s, panic→reboot) —
`esp_task_wdt_reconfigure()`, with an `esp_task_wdt_init()` fallback if it was
not inited. **Subscription policy (contracts/task-watchdog.md):** ONLY the two
watering-critical tasks subscribe (`esp_task_wdt_add(NULL)`) and feed
(`esp_task_wdt_reset()`) — the 10 Hz main loop (feeds each 100 ms iteration) and
the 5 s sensor task (feeds each cycle). The **WiFi task is deliberately NOT
subscribed** (a network stall must never reboot the device — FR-014 isolation)
and neither is the esp_console REPL (it blocks on UART by design). The 20 s
default keeps a safe margin over the slowest subscribed cadence (5 s) so a
healthy task is never falsely tripped. PR-11's watering/reservoir tasks register
through the same helper when they land. HIL checklist:
`specs/008-sntp-watchdog-logging/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/events/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# events — persistent event logging (feature 008 US2).
#
# EventLogger is PURE C++: it composes an IDataStorage& and an IWallClock&
# (both from the header-only `interfaces` component) and builds the category +
# detail string for each typed producer, then calls storeEvent(). It contains
# NO IDF/esp_* code — producers take primitive args (int reset reason, const
# char* state/cause strings), so this component stays free of any network or
# IDF coupling and compiles identically on the linux host (host-tested against
# MockDataStorage + FakeWallClock) and on the target. No linux guard needed.
idf_component_register(
SRCS "src/EventLogger.cpp"
INCLUDE_DIRS "include"
REQUIRES interfaces
)
87 changes: 87 additions & 0 deletions firmware/components/events/include/events/EventLogger.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// SPDX-FileCopyrightText: 2026 Cryptotomte
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* @file EventLogger.h
* @brief Typed producers for the PR-06 persistent event log (feature 008 US2).
*
* Composes an IDataStorage& (the cross-task LockedDataStorage when shared) and
* an IWallClock&; each producer picks the event category, builds a
* deterministic detail string and calls storage.storeEvent(clock.nowEpoch(),
* category, detail). Normative contract:
* specs/008-sntp-watchdog-logging/contracts/event-logger.md.
*
* PURE by design: no IDF/esp_* includes, no WifiState/esp_reset_reason_t enums
* in the signatures — producers take primitive args (int reset reason,
* const char* state/pump/cause strings) so this lives in the `events`
* component that depends only on `interfaces` and is host-testable against
* MockDataStorage + FakeWallClock.
*
* Never throws, never blocks watering: a storeEvent() returning false is
* counted (droppedEvents()) and dropped. A pure component cannot ESP_LOGW, so
* the counter is the surfaced signal — target callers may log it periodically.
* Credential VALUES are never logged (WiFi events carry the state name only).
*/

#ifndef WATERINGSYSTEM_EVENTS_EVENTLOGGER_H
#define WATERINGSYSTEM_EVENTS_EVENTLOGGER_H

#include <cstdint>
#include <string>

#include "interfaces/IDataStorage.h"
#include "interfaces/IWallClock.h"

/**
* @brief Map an esp_reset_reason_t integer value to a short name.
*
* Pure free function (no IDF include): the caller passes
* static_cast<int>(esp_reset_reason()). Total over the ESP_RST_* values defined
* by IDF v6 (0..15); any other value returns "UNKNOWN". Host-tested.
*/
const char* resetReasonName(int espResetReason);

/**
* @brief Builds + persists typed events over an injected IDataStorage.
*/
class EventLogger {
public:
/// Holds references (not ownership); both must outlive the logger. Pass the
/// cross-task LockedDataStorage as `storage` when the store is shared.
EventLogger(IDataStorage& storage, IWallClock& clock)
: storage_(storage), clock_(clock) {}

/// kCategoryReset, detail "reset=<reasonName>" (e.g. "reset=TASK_WDT").
/// `reason` is the raw esp_reset_reason_t int; the detail is built from the
/// internal resetReasonName(reason) mapping so the name cannot mismatch.
void logReset(int reason);

/// kCategoryConnectivity, detail "wifi=<stateName>" (e.g. "wifi=Connected").
void logWifi(const char* stateName);

/// kCategoryPump, detail "pump=<pump> start cause=<cause>".
void logPumpStart(const char* pump, const char* cause);

/// kCategoryPump, detail "pump=<pump> stop cause=<cause>".
void logPumpStop(const char* pump, const char* cause);

/// kCategoryFailsafe, detail passed verbatim (producer is PR-11).
void logFailsafe(const char* detail);

/// kCategoryOta, detail passed verbatim (producer is PR-13).
void logOta(const char* detail);

/// Number of events dropped because storeEvent() returned false. Never
/// resets; a pure component's only failure signal (no ESP_LOGW here).
uint32_t droppedEvents() const { return droppedEvents_; }

private:
/// Single write path: stamp with the wall clock, store, count a failure.
/// Never throws.
void emit(uint8_t category, const std::string& detail);

IDataStorage& storage_;
IWallClock& clock_;
uint32_t droppedEvents_ = 0;
};

#endif /* WATERINGSYSTEM_EVENTS_EVENTLOGGER_H */
86 changes: 86 additions & 0 deletions firmware/components/events/src/EventLogger.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// SPDX-FileCopyrightText: 2026 Cryptotomte
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* @file EventLogger.cpp
* @brief Typed event producers + reset-reason name mapping (pure).
*
* No IDF/esp_* includes: detail strings are built with std::string only and
* category constants come from IDataStorage. See EventLogger.h for the
* behavioural contract.
*/

#include "events/EventLogger.h"

const char* resetReasonName(int espResetReason)
{
// Mirrors esp_reset_reason_t (esp_system.h) by integer value so this stays
// pure (no IDF include). Total: any unlisted value maps to "UNKNOWN".
switch (espResetReason) {
case 0: return "UNKNOWN"; // ESP_RST_UNKNOWN
case 1: return "POWERON"; // ESP_RST_POWERON
case 2: return "EXT"; // ESP_RST_EXT
case 3: return "SW"; // ESP_RST_SW
case 4: return "PANIC"; // ESP_RST_PANIC
case 5: return "INT_WDT"; // ESP_RST_INT_WDT
case 6: return "TASK_WDT"; // ESP_RST_TASK_WDT
case 7: return "WDT"; // ESP_RST_WDT
case 8: return "DEEPSLEEP"; // ESP_RST_DEEPSLEEP
case 9: return "BROWNOUT"; // ESP_RST_BROWNOUT
case 10: return "SDIO"; // ESP_RST_SDIO
case 11: return "USB"; // ESP_RST_USB
case 12: return "JTAG"; // ESP_RST_JTAG
case 13: return "EFUSE"; // ESP_RST_EFUSE
case 14: return "PWR_GLITCH"; // ESP_RST_PWR_GLITCH
case 15: return "CPU_LOCKUP"; // ESP_RST_CPU_LOCKUP
default: return "UNKNOWN";
}
}

void EventLogger::emit(uint8_t category, const std::string& detail)
{
// Never throws / never blocks watering: a failed append is counted only
// (pure component — no ESP_LOGW). The store truncates over-long detail at
// kEventDetailMaxLen, so producers need not pre-truncate.
if (!storage_.storeEvent(clock_.nowEpoch(), category, detail)) {
++droppedEvents_;
}
}

void EventLogger::logReset(int reason)
{
// The detail carries the mapped human-readable name only (contract example:
// "reset=TASK_WDT"); mapping internally makes a name mismatch unrepresentable.
emit(IDataStorage::kCategoryReset,
std::string("reset=") + resetReasonName(reason));
}

void EventLogger::logWifi(const char* stateName)
{
// The STATE name only — never the SSID/password (FR-004).
emit(IDataStorage::kCategoryConnectivity,
std::string("wifi=") + (stateName ? stateName : "unknown"));
}

void EventLogger::logPumpStart(const char* pump, const char* cause)
{
emit(IDataStorage::kCategoryPump,
std::string("pump=") + (pump ? pump : "?") + " start cause=" +
(cause ? cause : "unknown"));
}

void EventLogger::logPumpStop(const char* pump, const char* cause)
{
emit(IDataStorage::kCategoryPump,
std::string("pump=") + (pump ? pump : "?") + " stop cause=" +
(cause ? cause : "unknown"));
}

void EventLogger::logFailsafe(const char* detail)
{
emit(IDataStorage::kCategoryFailsafe, detail ? detail : "");
}

void EventLogger::logOta(const char* detail)
{
emit(IDataStorage::kCategoryOta, detail ? detail : "");
}
46 changes: 46 additions & 0 deletions firmware/components/interfaces/include/interfaces/IWallClock.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2026 Cryptotomte
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* @file IWallClock.h
* @brief Injected wall-clock time source (epoch seconds).
*
* Separate from ITimeProvider (monotonic milliseconds for timing/safety):
* this is calendar/wall-clock time used only for stamping events and
* user-facing time display. It is injected so consumers (e.g. EventLogger)
* stay deterministic under host tests (FakeWallClock) and never call
* time()/esp_* directly.
*
* Part of the header-only `interfaces` component: no IDF and no <ctime>
* includes allowed here — only <cstdint>.
*/

#ifndef WATERINGSYSTEM_INTERFACES_IWALLCLOCK_H
#define WATERINGSYSTEM_INTERFACES_IWALLCLOCK_H

#include <cstdint>

/**
* @brief Wall-clock (calendar) time source in epoch seconds.
*/
class IWallClock {
public:
virtual ~IWallClock() = default;

/**
* @brief Current wall-clock time in seconds since the Unix epoch.
*
* Returns a low/boot value (well below a plausible present-day epoch)
* until the clock has been set (e.g. by SNTP). Callers that need to
* distinguish "not yet set" MUST consult isTimeSet().
*/
virtual uint32_t nowEpoch() const = 0;

/**
* @brief True iff nowEpoch() is at or beyond the plausibility threshold.
*
* False before the wall clock has been set from a trusted source.
*/
virtual bool isTimeSet() const = 0;
};

#endif /* WATERINGSYSTEM_INTERFACES_IWALLCLOCK_H */
30 changes: 30 additions & 0 deletions firmware/components/time/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# time — wall-clock time: pure conversion/plausibility (TimeService), the
# IWallClock target implementation (SystemWallClock) and the SNTP starter
# (SntpClient).
#
# TimeService.cpp is pure C++ (epoch plausibility + Swedish local-time
# formatting via <ctime>/localtime_r, which both the host and target provide)
# and builds on the linux preview target used by the host test suite.
# SystemWallClock.cpp is also pure POSIX (time(nullptr)); it is kept target-side
# by convention only (host tests inject FakeWallClock instead). SntpClient.cpp
# (esp_netif_sntp against CONFIG_WS_SNTP_SERVER + TZ/DST setup) is the only
# genuine IDF touchpoint. Both target-only .cpp files are excluded from the linux
# build, same mechanism as storage/sensors/network; esp_netif provides
# esp_netif_sntp.h. Those esp_* headers appear only in SntpClient.cpp, never in
# public headers.
if(${IDF_TARGET} STREQUAL "linux")
idf_component_register(
SRCS "src/TimeService.cpp"
INCLUDE_DIRS "include"
REQUIRES interfaces
)
else()
idf_component_register(
SRCS "src/TimeService.cpp"
"src/SystemWallClock.cpp"
"src/SntpClient.cpp"
INCLUDE_DIRS "include"
REQUIRES interfaces
PRIV_REQUIRES esp_netif
)
endif()
69 changes: 69 additions & 0 deletions firmware/components/time/include/time/SntpClient.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: 2026 Cryptotomte
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* @file SntpClient.h
* @brief SNTP wall-clock synchroniser + timezone setup — target only.
*
* Thin, non-blocking starter around esp_netif_sntp. applyTimezone() installs
* the Swedish TZ (CET-1CEST,M3.5.0,M10.5.0/3) once; start() launches the SNTP
* service against CONFIG_WS_SNTP_SERVER in step-set mode and is idempotent and
* non-fatal (server unreachable is retried by the SNTP service, never a boot
* failure). A static sync callback updates the client's SyncStatus on each
* successful sync. Excluded from the linux host build (esp_* dependencies).
*/

#ifndef WATERINGSYSTEM_TIME_SNTPCLIENT_H
#define WATERINGSYSTEM_TIME_SNTPCLIENT_H

#include <sys/time.h>

#include "time/SyncStatus.h"

/**
* @brief Starts SNTP synchronisation and tracks the resulting SyncStatus.
*
* Single-instance by design: the ESP-IDF SNTP module is a process-global
* service, and the sync callback routes into the one live instance so the
* console `time` line can report sync state.
*/
class SntpClient {
public:
SntpClient();
~SntpClient();

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

/**
* @brief Install the Swedish timezone (CET/CEST) into the process env.
*
* `setenv("TZ", "CET-1CEST,M3.5.0,M10.5.0/3", 1); tzset();`. Call once at
* init, before any local-time rendering.
*/
void applyTimezone();

/**
* @brief Start the SNTP service (idempotent, non-blocking, non-fatal).
*
* Initialises esp_netif_sntp against CONFIG_WS_SNTP_SERVER in step-set
* (immediate) mode with the sync callback registered. Re-invocation is a
* no-op (an already-initialised service is treated as success). Reaching the
* server later is the SNTP service's own job; this call never blocks on it.
*
* @return true if the SNTP service is now running (init OK, or already
* inited); false if init failed — the caller should retry later.
*/
bool start();

/// Immutable view of the current synchronisation state.
const SyncStatus& status() const { return status_; }

private:
/// C-ABI SNTP time-sync callback; routes to the live instance.
static void onSyncCb(struct timeval* tv);

SyncStatus status_;
bool started_ = false;
};

#endif /* WATERINGSYSTEM_TIME_SNTPCLIENT_H */
Loading
Loading