diff --git a/openspec/changes/controller-architecture-contracts/tasks.md b/openspec/changes/controller-architecture-contracts/tasks.md index 28c226f3..bf5562df 100644 --- a/openspec/changes/controller-architecture-contracts/tasks.md +++ b/openspec/changes/controller-architecture-contracts/tasks.md @@ -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] Add a bounded, allocation-free `ControllerCommandQueue` for adapter-to-loop hand-over. - [ ] In #216/#217, verify the expanded payloads and transport resource budgets. - [ ] In #218, validate Web/legacy NTP input without silent truncation and complete owner/adapter regressions. - [ ] In #218/#220, verify both-bus inventory aggregation, deduplication, overflow and rescan replacement on the owning task. diff --git a/src/ControllerCommandQueue.hpp b/src/ControllerCommandQueue.hpp new file mode 100644 index 00000000..0beda38a --- /dev/null +++ b/src/ControllerCommandQueue.hpp @@ -0,0 +1,88 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file ControllerCommandQueue.hpp + * @brief Bounded hand-over queue for typed controller commands. + */ + +#pragma once + +#include + +#include "ControllerCommand.hpp" + +#if defined(ESP32) || defined(ARDUINO_ARCH_ESP32) +#include +#include +#else +#include +#endif + +namespace PoolController { + +/** + * @brief Fixed-capacity queue for commands crossing callback/task boundaries. + * + * The queue owns copies of trivially-copyable `ControllerCommand` values and + * never allocates memory. It is safe for multiple adapter producers and a + * single Core-1 application consumer. + */ +class ControllerCommandQueue final { +public: + static constexpr std::size_t kCapacity = 16; + + enum class PushResult : std::uint8_t { + OK, + FULL, + }; + + PushResult push(const ControllerCommand &command) { + lock(); + if (count_ == kCapacity) { + unlock(); + return PushResult::FULL; + } + buffer_[(head_ + count_) % kCapacity] = command; + ++count_; + unlock(); + return PushResult::OK; + } + + bool pop(ControllerCommand &out) { + lock(); + if (count_ == 0) { + unlock(); + return false; + } + out = buffer_[head_]; + head_ = (head_ + 1) % kCapacity; + --count_; + unlock(); + return true; + } + + std::size_t size() { + lock(); + const std::size_t result = count_; + unlock(); + return result; + } + +private: +#if defined(ESP32) || defined(ARDUINO_ARCH_ESP32) + portMUX_TYPE mux_ = portMUX_INITIALIZER_UNLOCKED; + void lock() { portENTER_CRITICAL(&mux_); } + void unlock() { portEXIT_CRITICAL(&mux_); } +#else + std::mutex mutex_; + void lock() { mutex_.lock(); } + void unlock() { mutex_.unlock(); } +#endif + + ControllerCommand buffer_[kCapacity]{}; + std::size_t head_{0}; + std::size_t count_{0}; +}; + +} // namespace PoolController diff --git a/test/native/CMakeLists.txt b/test/native/CMakeLists.txt index fb103dea..c02f444e 100644 --- a/test/native/CMakeLists.txt +++ b/test/native/CMakeLists.txt @@ -91,6 +91,7 @@ set(TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_mqtt_command_queue.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_controller_contracts.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_controller_snapshot_diagnostics.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_controller_command_queue.cpp ) add_executable(test_runner diff --git a/test/native/tests/test_controller_command_queue.cpp b/test/native/tests/test_controller_command_queue.cpp new file mode 100644 index 00000000..04e9a74e --- /dev/null +++ b/test/native/tests/test_controller_command_queue.cpp @@ -0,0 +1,103 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file test_controller_command_queue.cpp + * @brief Tests for the bounded typed controller command queue. + */ + +#include "ControllerCommandQueue.hpp" + +extern void test_begin(const char *suite, const char *name); +extern void test_pass(const char *file, int line); +extern void test_fail(const char *file, int line, const char *msg); +extern void test_suite_end(const char *name, int passed, int failed); + +#define ASSERT_TRUE(cond) \ + do { \ + if (!(cond)) { \ + test_fail(__FILE__, __LINE__, "Expected true: " #cond); \ + return 1; \ + } \ + test_pass(__FILE__, __LINE__); \ + } while (0) + +#define ASSERT_EQ(a, b) ASSERT_TRUE((a) == (b)) + +using PoolController::CommandSource; +using PoolController::ControllerCommand; +using PoolController::ControllerCommandQueue; +using PoolController::ControllerCommandType; +using PoolController::OperationMode; + +static int test_fifo_order() { + test_begin("ControllerCommandQueue", "commands are returned in FIFO order"); + ControllerCommandQueue queue; + + ControllerCommand first{}; + first.type = ControllerCommandType::SET_MODE; + first.source = CommandSource::MQTT; + first.mode = OperationMode::BOOST; + + ControllerCommand second{}; + second.type = ControllerCommandType::SET_POOL_MAX_TEMPERATURE; + second.source = CommandSource::WEB; + second.value = 29.5F; + + ASSERT_EQ(queue.push(first), ControllerCommandQueue::PushResult::OK); + ASSERT_EQ(queue.push(second), ControllerCommandQueue::PushResult::OK); + ASSERT_EQ(queue.size(), 2U); + + ControllerCommand out{}; + ASSERT_TRUE(queue.pop(out)); + ASSERT_EQ(out.type, ControllerCommandType::SET_MODE); + ASSERT_EQ(out.source, CommandSource::MQTT); + ASSERT_EQ(out.mode, OperationMode::BOOST); + + ASSERT_TRUE(queue.pop(out)); + ASSERT_EQ(out.type, ControllerCommandType::SET_POOL_MAX_TEMPERATURE); + ASSERT_EQ(out.source, CommandSource::WEB); + ASSERT_EQ(out.value, 29.5F); + ASSERT_TRUE(!queue.pop(out)); + return 0; +} + +static int test_full_queue_and_wraparound() { + test_begin("ControllerCommandQueue", "full queue rejects and reuses released slots"); + ControllerCommandQueue queue; + ControllerCommand command{}; + command.type = ControllerCommandType::SET_MODE; + + for (std::size_t i = 0; i < ControllerCommandQueue::kCapacity; ++i) { + command.value = static_cast(i); + ASSERT_EQ(queue.push(command), ControllerCommandQueue::PushResult::OK); + } + ASSERT_EQ(queue.push(command), ControllerCommandQueue::PushResult::FULL); + + ControllerCommand out{}; + ASSERT_TRUE(queue.pop(out)); + command.value = 99.0F; + ASSERT_EQ(queue.push(command), ControllerCommandQueue::PushResult::OK); + + std::size_t drained = 0; + while (queue.pop(out)) { + ++drained; + } + ASSERT_EQ(drained, ControllerCommandQueue::kCapacity); + return 0; +} + +int run_controller_command_queue_tests() { + int passed = 0; + int failed = 0; + int (*tests[])() = {test_fifo_order, test_full_queue_and_wraparound}; + for (auto test : tests) { + if (test() == 0) { + ++passed; + } else { + ++failed; + } + } + test_suite_end("ControllerCommandQueue", passed, failed); + return failed; +} diff --git a/test/native/tests/test_main.cpp b/test/native/tests/test_main.cpp index bffff5df..ce3a61b8 100644 --- a/test/native/tests/test_main.cpp +++ b/test/native/tests/test_main.cpp @@ -114,6 +114,7 @@ extern int run_calibration_manager_tests(); extern int run_mqtt_command_queue_tests(); extern int run_controller_contract_tests(); extern int run_controller_snapshot_diagnostics_tests(); +extern int run_controller_command_queue_tests(); int main() { printf("\n══════════════════════════════════════════════════\n"); @@ -136,6 +137,7 @@ int main() { total += run_mqtt_command_queue_tests(); total += run_controller_contract_tests(); total += run_controller_snapshot_diagnostics_tests(); + total += run_controller_command_queue_tests(); printf("\n══════════════════════════════════════════════════\n"); printf(" Results: %d suites passed, %d suites failed\n", g_testsPassed, g_testsFailed);