Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
5c53cb8
refactor(adapters): migrate MQTT, Web and UI to commands and snapshots
Oct 11, 2026
154419f
style: apply clang-format to native mocks
Oct 11, 2026
2450992
fix(adapters): address Codex review findings in command/snapshot migr…
Oct 11, 2026
4ce44e8
fix: correct NtpConfig scope and add cached mapping accessor to test …
Oct 11, 2026
8e2e482
fix: reference global mqttCapture in adapter regression test
Oct 11, 2026
5f9dfb1
fix: clear stale mock MQTT registrations between adapter test fixtures
Oct 11, 2026
ed90398
fix: mirror single-dispatcher MQTT registration in NetworkManager mock
Oct 11, 2026
69ed7e3
fix: re-register mock MQTT dispatcher after capture resets
Oct 11, 2026
0461f03
fix: declare sensor-mapping cache before first use
Oct 11, 2026
f31ebfb
test: raise native line coverage to 89% with real DegradationManager …
Oct 11, 2026
265bb6b
style: avoid ASSERT_TRUE comparison form flagged by cpplint
Oct 11, 2026
f5168f9
style: use String::isEmpty() in OTA lifecycle test
Oct 11, 2026
3ac4732
test: model detected Dallas inventory
stritti Oct 11, 2026
cd119fa
test: make local IP configurable
stritti Oct 11, 2026
9765f58
test: seed local IP state
stritti Oct 11, 2026
3711c86
test: observe verbose rule lookup
stritti Oct 11, 2026
1aa3c6b
test: cover review regressions in command handler
stritti Oct 11, 2026
5f76944
test: cover snapshot projection review regressions
stritti Oct 11, 2026
9ca33a4
test: model partial sensor mappings
stritti Oct 11, 2026
cc8bf3b
test: seed partial sensor mapping state
stritti Oct 11, 2026
1bc7019
test: preserve independently stored sensor mapping
stritti Oct 11, 2026
be5f352
test: fix partial mapping mock include
stritti Oct 11, 2026
00eea69
test: isolate persistence failure hook
stritti Oct 11, 2026
b7fe7a5
test: isolate NTP snapshot regression
stritti Oct 11, 2026
4e96f0e
fix: keep command rejection atomic
stritti Oct 11, 2026
579fcbb
fix: preserve partial sensor mappings
stritti Oct 11, 2026
3e018e8
docs: define partial sensor mapping semantics
stritti Oct 11, 2026
7a6f3bb
test: align mapping cache semantics
stritti Oct 11, 2026
d87277c
test: preserve partial mapping in mock
stritti Oct 11, 2026
0acbfa8
refactor: expose value IP address for snapshots
stritti Oct 11, 2026
b2c3526
refactor: support quiet active-rule reads
stritti Oct 11, 2026
d6fd493
fix: avoid snapshot rule log flooding
stritti Oct 11, 2026
87829b2
fix: complete immutable snapshot projection
stritti Oct 11, 2026
ceb0087
fix: retain sensor mapping cache validity
stritti Oct 11, 2026
c1aa5f9
docs: record snapshot and atomic command invariants
stritti Oct 11, 2026
da0f798
docs: sync review regression work
stritti Oct 11, 2026
db01ed1
style: apply clang-format to snapshot publisher
stritti Oct 11, 2026
fb25c16
style: apply clang-format to command tests
stritti Oct 11, 2026
b3995ce
docs: fix web UI skill markdownlint
stritti Oct 11, 2026
952276c
docs: wrap Olimex implementation plan
stritti Oct 11, 2026
7a28875
docs: wrap NORVI calibration plan
stritti Oct 11, 2026
a9c03cf
docs: wrap adapter migration design
stritti Oct 11, 2026
7e1afb7
docs: wrap architecture contract design
stritti Oct 11, 2026
7860f31
fix: make reviewed command mutations atomic
stritti Oct 12, 2026
c1638b3
fix: keep sensor mapping cache retryable
stritti Oct 12, 2026
8c04553
docs: clarify sensor mapping cache contract
stritti Oct 12, 2026
ea55a48
refactor: cache detected sensor inventory
stritti Oct 12, 2026
44f617b
fix: bound Dallas inventory reads in snapshots
stritti Oct 12, 2026
4809cd3
test: expose Dallas inventory read count
stritti Oct 12, 2026
d0a5926
test: model transient sensor mapping read failures
stritti Oct 12, 2026
bee0a6f
test: initialize sensor mapping failure hook
stritti Oct 12, 2026
bd689e9
test: cover reviewed command failure paths
stritti Oct 12, 2026
6fecad0
test: cover bounded and invalid Dallas snapshots
stritti Oct 12, 2026
d66ba48
docs: record reviewed adapter invariants
stritti Oct 12, 2026
685069d
fix: support ESP32 C++ standard in mapping read result
stritti Oct 12, 2026
6c9ec67
style: format snapshot refresh interval
stritti Oct 12, 2026
fc1aaa2
test: use comparison assertion for inventory reads
stritti Oct 12, 2026
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
1 change: 0 additions & 1 deletion .opencode/skills/web-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ keywords:
Web interface development for the ESP32 pool-controller. The UI lives in two layers: **LittleFS** (editable at runtime) and **PROGMEM** (embedded fallback in the firmware binary).

> **Note:** The Olimex local TFT UI is a separate on-device interface (Adafruit GFX + ILI9341/ST7789) and is not the browser/LittleFS dashboard.

> **🔍 Code Search**: Use `semble search "LittleFS.open"` or `semble search "kPortalPageHtml"` to find the fallback architecture. See `Agents.md` §7 for full `semble` usage.

---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ constexpr bool TFT_DRIVER_ILI9341{true};
constexpr bool TFT_DRIVER_ST7789{false};
```

Do not use GPIO1/2/3/15 for UI; those are Olimex opto inputs. Do not use GPIO10/11/22/23 except for relays. GPIO12/13/14 are not useful defaults because they are not exposed on the standard EVB headers. The defaults above intentionally keep the encoder on UEXT UART1 pins GPIO4/GPIO5 plus EXT1 GPIO0, keep TFT SPI on UEXT SPI, and avoid display reset by requiring the concrete TFT module to tie reset high or expose a reset pad that can be wired later.
Do not use GPIO1/2/3/15 for UI; those are Olimex opto inputs. Do not use GPIO10/11/22/23 except for relays.
GPIO12/13/14 are not useful defaults because they are not exposed on the standard EVB headers. The defaults above intentionally keep the encoder on UEXT UART1 pins GPIO4/GPIO5 plus EXT1 GPIO0, keep TFT SPI on UEXT SPI, and avoid display reset by requiring the concrete TFT module to tie reset high or expose a reset pad that can be wired later.

- [ ] **Step 2: Add static assertions for display driver selection**

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

**Goal:** Add a guided calibration wizard to the web UI that measures the NORVI AE01-R front-panel button ADC levels and derives the button thresholds automatically.

**Architecture:** A separate `CalibrationManager` module owns a calibration state machine (IDLE → RESTING → BTN1 → BTN2 → BTN3 → COMPUTE → SAVE → DONE/ERROR) with injectable ADC-read and time functions for native testing. `NorviButtonHandler::loop()` early-returns while calibration is active. WebPortal exposes three auth-protected REST endpoints; the web UI shows a modal wizard that polls the status endpoint.
**Architecture:** A separate `CalibrationManager` module owns a calibration state machine (IDLE → RESTING → BTN1 → BTN2 → BTN3 → COMPUTE → SAVE → DONE/ERROR) with injectable ADC-read and time functions for native testing.
`NorviButtonHandler::loop()` early-returns while calibration is active. WebPortal exposes three auth-protected REST endpoints; the web UI shows a modal wizard that polls the status endpoint.

**Tech Stack:** C++17 (ESP32/Arduino, PlatformIO env `norvi_ae01_r`), ArduinoJson, native CMake test runner (`test/native`), vanilla JS/CSS web assets on LittleFS.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-04
56 changes: 56 additions & 0 deletions openspec/changes/adapter-command-snapshot-migration/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Design: Adapter command and snapshot migration

## Ownership

The Core-1 application loop is the only owner allowed to mutate controller/domain state and runtime controller settings. Adapter callbacks may parse/validate protocol input, but they must not mutate nodes, runtime settings or relay state directly.

Provisioning/authentication data is deliberately separate: WiFi/MQTT credentials and admin-password flows remain owned by dedicated configuration/auth services and are not copied through the general controller command queue. Likewise, specialized calibration workflows remain behind their dedicated service API until the composition-root/configuration refactor gives them an explicit ownership boundary.

## Inbound path

```text
MQTT callback -> protocol validation -> ControllerCommandQueue
Web handler -> protocol validation -> ControllerCommandQueue
Local UI -> interaction mapping -> ControllerCommandQueue
-> Core-1 command handler -> state mutation
```

The typed path covers runtime controller mutations: mode, setpoints, circulation settings, independent timer start/end updates, loop interval, timezone/time-loss thresholds, NORVI button thresholds, sensor mappings and manual relay commands.
A rejected command must be atomic from the owner's perspective: if persistence fails after an in-memory mutation, both runtime state and the mutable settings projection are restored before failure is returned.
Sensor-role mappings are independent values, so reading or updating one missing role must never erase the other role.

For synchronous Web responses, the first migration may process the typed command immediately when the handler is already executing on the owning loop task. The command handler remains the single mutation API for that runtime state.

## Outbound path

```text
Core-1 state -> SystemSnapshot -> ControllerSnapshotStore
-> MQTT
-> Web status/config projections
-> OLED/TFT
```

Adapters receive value copies. They do not receive node pointers or mutable runtime-config references.
The snapshot must preserve the existing external projection, including timer runtime state, sensor mapping identity/configured/found state, deduplicated detected-sensor inventory, local IPv4 state, configured NTP server and GREEN/YELLOW/RED time degradation.
Snapshot publication must use read-only or quiet domain accessors so the high-frequency publication path does not create logging side effects.

## Migration order

1. Add a Core-1 command handler for the complete runtime `ControllerCommand` contract.
2. Route processed MQTT runtime commands through typed commands.
3. Route Web runtime mutations through the same handler.
4. Route local UI runtime mutations through the same handler.
5. Build and publish `SystemSnapshot` from the owning loop.
6. Migrate status/read paths one adapter at a time.
7. Remove adapter dependencies on `Nodes.hpp` and mutable runtime config where no longer needed.
8. Remove `MqttCommandQueue` after the typed queue owns callback hand-over.

## Verification

- native tests for command validation/dispatch and queue overflow
- regression tests for independent timer-start/timer-end updates
- existing MQTT/Web compatibility tests remain unchanged
- PlatformIO builds for all supported boards
- regression test proving MQTT callbacks cannot mutate operation mode or runtime settings directly
- grep/code-search gate showing migrated adapters no longer include `Nodes.hpp` for mutable state access
- verify provisioning/auth flows remain isolated from the general runtime command queue
27 changes: 27 additions & 0 deletions openspec/changes/adapter-command-snapshot-migration/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Change: Migrate adapters to typed commands and snapshots

## Why

MQTT, Web and local UI still depend directly on global nodes and static managers. That keeps protocol code coupled to mutable application state and makes the single-writer ownership model from #170 difficult to enforce structurally.

## What changes

- translate inbound MQTT/Web/local-UI runtime-controller mutations into `ControllerCommand` values
- process runtime commands only on the Core-1 application owner
- preserve independent timer-start/timer-end update behavior
- publish a complete `SystemSnapshot` after relevant state changes
- make MQTT/Web/display runtime output consume snapshots rather than `Nodes.hpp` or mutable controller settings
- retain existing external MQTT topics, Web payloads and behavior
- keep WiFi/MQTT credentials, admin-password flows and specialized calibration workflows in their dedicated services rather than transporting secrets through the general controller queue
- remove `MqttCommandQueue` only after the typed command path has equivalent coverage and bounded behavior

## Dependencies

- #214 controller architecture contracts
- #216 typed command queue foundation
- #217 snapshot-store foundation
- #170 multicore ownership boundary for sensor I/O

## Impact

The external interfaces remain compatible. The change is internal ownership/coupling cleanup and should reduce the number of translation units that can access mutable nodes and runtime controller settings directly.
26 changes: 26 additions & 0 deletions openspec/changes/adapter-command-snapshot-migration/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Tasks

- [x] Introduce a single Core-1 controller command handler for the complete runtime command contract.
- [x] Route processed MQTT runtime commands through typed `ControllerCommand` values.
- [x] Preserve independent MQTT timer-start and timer-end updates without stale-state reconstruction.
- [x] Route `/api/mode` and `/api/pump` Web mutations through the same command handler using owner-resolved relative actions.
- [ ] Route the remaining Web runtime mutations through the same command handler.
- [ ] Route local UI runtime mutations through the same command handler.
- [x] Keep provisioning/authentication secrets and specialized calibration workflows behind their dedicated service boundaries.
- [x] Publish `SystemSnapshot` from the Core-1 owner after state updates.
- [x] Populate sensor mapping address/configured/found state in the snapshot.
- [x] Preserve independently configured sensor mappings when the other role is absent.
- [x] Populate detected-sensor inventory, local IPv4 state and configured NTP server in the snapshot.
- [x] Bound detected-sensor inventory refreshes so snapshot publication does not poll Dallas devices on every loop.
- [x] Keep transient sensor-mapping NVS failures retryable and reject updates that cannot preserve the other role.
- [x] Populate GREEN/YELLOW/RED time degradation and runtime controller settings in the snapshot.
- [x] Enforce the persisted/runtime degradation invariant `1 <= green < red <= 72`.
- [x] Keep failed persisted runtime mutations atomic by rolling back owner state and settings.
- [x] Keep snapshot publication free of active-rule debug-log side effects.
- [ ] Migrate MQTT state/discovery reads to snapshot values where applicable.
- [ ] Migrate Web status/config read paths to snapshot values where applicable.
- [ ] Migrate OLED/TFT read paths to snapshot values.
- [ ] Remove direct mutable `Nodes.hpp` and runtime-setting access from migrated adapters.
- [ ] Remove `MqttCommandQueue` after typed callback hand-over has equivalent tests.
- [x] Add native regression tests for dispatch, overflow, partial timer updates and MQTT compatibility.
- [ ] Verify all PlatformIO environments and existing API/MQTT contract tests.
3 changes: 2 additions & 1 deletion openspec/changes/controller-architecture-contracts/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ branches after the foundation merges; #218 already contains queue/store code and
adopted.
- This PR introduces no active instances/call-site changes, so production RAM/Flash/CPU behavior remains unchanged. Reverting its contract commit
is sufficient before adoption; after adoption, dependent consumers must be reverted together.
- Known extensions deliberately deferred out of this PR are tracked in #222: current-epoch projection, `minFreeHeapBytes`, active SSID, batch semantics for the full Web settings form, and a bounded mode-audit origin discriminator. They must be decided before #218 adopts these read models; epoch and min-heap additions exceed the current 600-byte `SystemSnapshot` guard (596 bytes used), so that guard gets revised with the extension, not silently.
- Known extensions deliberately deferred out of this PR are tracked in #222: current-epoch projection, `minFreeHeapBytes`, active SSID, batch semantics for the full Web settings form, and a bounded mode-audit origin discriminator.
They must be decided before #218 adopts these read models; epoch and min-heap additions exceed the current 600-byte `SystemSnapshot` guard (596 bytes used), so that guard gets revised with the extension, not silently.

## Concurrency invariants

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- [x] Project the detected inventory with a 20-device cap, ROMs and independent temperature validity.
- [x] Add native tests for compatibility, text boundaries, full/empty inventory, diagnostics and copy semantics.
- [x] Document the minimal dependency graph, current #218 progress, #170 ownership gate and sensor projection bridge.
- [x] Store operation mode internally as the typed enum while preserving String compatibility at boundaries.
- [x] Add a bounded, allocation-free `ControllerCommandQueue` for adapter-to-loop hand-over.
- [x] Add a synchronized `ControllerSnapshotStore` for complete read-model publication.
- [ ] In #216/#217, verify the expanded payloads and transport resource budgets.
Expand Down
71 changes: 62 additions & 9 deletions src/ConfigManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

#include "ConfigManager.hpp"

#include <cstring>

#include <Preferences.h>
#include <mbedtls/md.h>

Expand Down Expand Up @@ -234,7 +236,46 @@ bool ConfigManager::verifyAdminPassword(const String &password) {
return adminPasswordHash_ == hash;
}

namespace {
// Process-wide sensor-mapping cache so hot loops avoid NVS traffic.
bool s_mappingCacheValid = false;
std::uint8_t s_cachedSolarAddress[8] = {0};
std::uint8_t s_cachedPoolAddress[8] = {0};

struct SensorMappingReadResult final {
bool readSucceeded{false};
bool hasMapping{false};
};

SensorMappingReadResult readSensorMappingFromNvs(uint8_t solarAddr[8], uint8_t poolAddr[8]) {
memset(solarAddr, 0, 8);
memset(poolAddr, 0, 8);

Preferences prefs;
if (!prefs.begin("ds18b20", true)) {
return {};
}

const size_t solarLength = prefs.getBytes("solar_adr", solarAddr, 8);
const size_t poolLength = prefs.getBytes("pool_adr", poolAddr, 8);
prefs.end();

if (solarLength != 8) {
memset(solarAddr, 0, 8);
}
if (poolLength != 8) {
memset(poolAddr, 0, 8);
}

SensorMappingReadResult result;
result.readSucceeded = true;
result.hasMapping = solarLength == 8 || poolLength == 8;
return result;
}
} // namespace

void ConfigManager::saveSensorMapping(const uint8_t solarAddr[8], const uint8_t poolAddr[8]) {
s_mappingCacheValid = false;
Preferences prefs;
prefs.begin("ds18b20", false); // read-write
prefs.putBytes("solar_adr", solarAddr, 8);
Expand All @@ -251,16 +292,28 @@ void ConfigManager::saveSensorMapping(const uint8_t solarAddr[8], const uint8_t
}

bool ConfigManager::loadSensorMapping(uint8_t solarAddr[8], uint8_t poolAddr[8]) {
Preferences prefs;
prefs.begin("ds18b20", true); // read-only

size_t slen = prefs.getBytes("solar_adr", solarAddr, 8);
size_t plen = prefs.getBytes("pool_adr", poolAddr, 8);

prefs.end();
// Preserve the legacy return contract: true means at least one role exists.
return readSensorMappingFromNvs(solarAddr, poolAddr).hasMapping;
}

// Return true if both addresses were read successfully
return (slen == 8 && plen == 8);
bool ConfigManager::loadSensorMappingCached(uint8_t solarAddr[8], uint8_t poolAddr[8]) {
if (!s_mappingCacheValid) {
std::uint8_t solarCandidate[8] = {0};
std::uint8_t poolCandidate[8] = {0};
const SensorMappingReadResult readResult = readSensorMappingFromNvs(solarCandidate, poolCandidate);
if (!readResult.readSucceeded) {
// Do not poison the process-wide cache on a transient NVS failure.
memset(solarAddr, 0, 8);
memset(poolAddr, 0, 8);
return false;
}
memcpy(s_cachedSolarAddress, solarCandidate, 8);
memcpy(s_cachedPoolAddress, poolCandidate, 8);
s_mappingCacheValid = true;
}
memcpy(solarAddr, s_cachedSolarAddress, 8);
memcpy(poolAddr, s_cachedPoolAddress, 8);
return true;
}

/** @brief Check if an 8-byte address is all zeros. */
Expand Down
14 changes: 13 additions & 1 deletion src/ConfigManager.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,20 @@ class ConfigManager {
// ── Sensor Address Mapping ──
/** @brief Save DS18B20 sensor address mapping to NVS. */
static void saveSensorMapping(const uint8_t solarAddr[8], const uint8_t poolAddr[8]);
/** @brief Load DS18B20 sensor address mapping from NVS. @return true if both addresses loaded. */
/** @brief Load DS18B20 mappings from NVS. Missing roles remain zeroed.
* @return true if at least one role mapping was loaded.
*/
static bool loadSensorMapping(uint8_t solarAddr[8], uint8_t poolAddr[8]);
/** @brief Load the sensor mapping through a process-wide cache.
*
* The first successful call reads NVS; subsequent calls serve the cached
* pair. Transient NVS-open failures are not cached and are retried later.
* The cache is invalidated by saveSensorMapping() so hot loops never touch
* NVS for unchanged mappings.
* @return true when NVS was read successfully or a valid cache exists;
* false on a transient read failure. Empty mappings are successful.
*/
static bool loadSensorMappingCached(uint8_t solarAddr[8], uint8_t poolAddr[8]);

// ── Boot Version Tracking ──
/// Call once after loading config to log OTA transition status.
Expand Down
Loading
Loading