From d76a9f33cb89ba11d50f19e0412e033b14c427a2 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 19:21:11 +0200 Subject: [PATCH 01/13] feat: add command registry observer contract --- src/ESPressio_ICommandRegistryObserver.hpp | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/ESPressio_ICommandRegistryObserver.hpp diff --git a/src/ESPressio_ICommandRegistryObserver.hpp b/src/ESPressio_ICommandRegistryObserver.hpp new file mode 100644 index 0000000..d44f2c6 --- /dev/null +++ b/src/ESPressio_ICommandRegistryObserver.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +#include + +namespace ESPressio::Command { + +class ICommandRegistryObserver : + public virtual Observable::IObserver { +public: + virtual ~ICommandRegistryObserver() = default; + + virtual void OnCommandRegistered( + const std::vector& + ) {} + + virtual void OnCommandUnregistered( + const std::vector& + ) {} +}; + +} // namespace ESPressio::Command From 8f9bc8a97f5c1a5a02f61fb30680405e0555eda7 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 19:22:11 +0200 Subject: [PATCH 02/13] feat: make command registry observable --- src/ESPressio_Command.hpp | 82 ++++++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/src/ESPressio_Command.hpp b/src/ESPressio_Command.hpp index a9f7dbb..5f9b593 100644 --- a/src/ESPressio_Command.hpp +++ b/src/ESPressio_Command.hpp @@ -15,6 +15,10 @@ #include #include +#include + +#include "ESPressio_ICommandRegistryObserver.hpp" + namespace ESPressio::Command { struct CommandResult { @@ -45,7 +49,6 @@ class CommandContext { return it->second; } const CommandInvocation& Invocation() const { return invocation_; } - template T Get(const std::string& name) const { return Convert(Raw(name)); } private: @@ -79,7 +82,8 @@ class CommandContext { return static_cast(parsed); } } else if constexpr (std::is_floating_point_v) { - std::size_t used = 0; long double parsed = std::stold(value, &used); + std::size_t used = 0; + long double parsed = std::stold(value, &used); if (used != value.size()) throw std::invalid_argument("Expected numeric value: " + value); return static_cast(parsed); } else { @@ -102,7 +106,6 @@ class CommandParameter { CommandParameter& Range(long double min, long double max) { hasRange_ = true; min_ = min; max_ = max; return *this; } CommandParameter& OneOf(std::vector values) { choices_ = std::move(values); return *this; } CommandParameter& Validator(std::function fn, std::string message = "Validation failed") { validator_ = std::move(fn); validatorMessage_ = std::move(message); return *this; } - const std::string& Name() const { return name_; } const std::string& DescriptionText() const { return description_; } bool IsRequired() const { return required_; } @@ -112,25 +115,15 @@ class CommandParameter { ParameterKind Kind() const { return kind_; } const std::vector& Aliases() const { return aliases_; } const std::vector& Choices() const { return choices_; } - - bool Matches(const std::string& key) const { - if (key == name_) return true; - return std::find(aliases_.begin(), aliases_.end(), key) != aliases_.end(); - } + bool Matches(const std::string& key) const { return key == name_ || std::find(aliases_.begin(), aliases_.end(), key) != aliases_.end(); } std::string Validate(const std::string& value) const { try { switch (kind_) { case ParameterKind::Boolean: (void)CommandContext::Convert(value); break; - case ParameterKind::SignedInteger: { - auto v = CommandContext::Convert(value); if (hasRange_ && (v < min_ || v > max_)) return "Value for '" + name_ + "' is outside the allowed range"; break; - } - case ParameterKind::UnsignedInteger: { - auto v = CommandContext::Convert(value); if (hasRange_ && (v < min_ || v > max_)) return "Value for '" + name_ + "' is outside the allowed range"; break; - } - case ParameterKind::FloatingPoint: { - auto v = CommandContext::Convert(value); if (hasRange_ && (v < min_ || v > max_)) return "Value for '" + name_ + "' is outside the allowed range"; break; - } + case ParameterKind::SignedInteger: { auto v = CommandContext::Convert(value); if (hasRange_ && (v < min_ || v > max_)) return "Value for '" + name_ + "' is outside the allowed range"; break; } + case ParameterKind::UnsignedInteger: { auto v = CommandContext::Convert(value); if (hasRange_ && (v < min_ || v > max_)) return "Value for '" + name_ + "' is outside the allowed range"; break; } + case ParameterKind::FloatingPoint: { auto v = CommandContext::Convert(value); if (hasRange_ && (v < min_ || v > max_)) return "Value for '" + name_ + "' is outside the allowed range"; break; } default: break; } } catch (const std::exception& e) { return "Invalid value for '" + name_ + "': " + e.what(); } @@ -160,12 +153,7 @@ class CommandNode { CommandNode& OnExecute(Callback cb) { callback_ = std::move(cb); return *this; } CommandNode& Before(Callback cb) { before_.push_back(std::move(cb)); return *this; } CommandNode& After(Callback cb) { after_.push_back(std::move(cb)); return *this; } - - CommandNode& Command(std::string name) { - for (auto& child : children_) if (child->Matches(name)) return *child; - children_.push_back(std::make_unique(std::move(name))); - return *children_.back(); - } + CommandNode& Command(std::string name) { for (auto& child : children_) if (child->Matches(name)) return *child; children_.push_back(std::make_unique(std::move(name))); return *children_.back(); } CommandParameter& Parameter(std::string name, ParameterKind kind = ParameterKind::String) { parameters_.emplace_back(std::move(name), kind); return parameters_.back(); } bool RemoveCommand(const std::string& name) { auto it = std::find_if(children_.begin(), children_.end(), [&](const auto& child){ return child->Matches(name); }); if (it == children_.end()) return false; children_.erase(it); return true; } template CommandParameter& Parameter(std::string name) { @@ -175,7 +163,6 @@ class CommandNode { else if constexpr (std::is_floating_point_v) return Parameter(std::move(name), ParameterKind::FloatingPoint); else return Parameter(std::move(name), ParameterKind::String); } - bool Matches(const std::string& value) const { return value == name_ || std::find(aliases_.begin(), aliases_.end(), value) != aliases_.end(); } const std::string& Name() const { return name_; } const std::string& DescriptionText() const { return description_; } @@ -233,17 +220,46 @@ class CommandRegistrationHandle { }; class CommandRegistry { +private: + class RegistryObservable final : public Observable::Observable { + private: + template + void Notify(Callback&& callback) { + ExecuteNotification([&](NotificationContext& notification) { + notification.WithObservers([&](ICommandRegistryObserver* observer) { + try { callback(observer); } catch (...) {} + }); + }); + } + public: + void Registered(const std::vector& path) { Notify([&](ICommandRegistryObserver* observer){ observer->OnCommandRegistered(path); }); } + void Unregistered(const std::vector& path) { Notify([&](ICommandRegistryObserver* observer){ observer->OnCommandUnregistered(path); }); } + }; + public: using Middleware = std::function&)>; - CommandRegistry() : root_("") {} + CommandRegistry() : root_(""), observable_(std::make_shared()) {} static CommandRegistry& GetInstance() { static CommandRegistry instance; return instance; } - CommandNode& Command(std::string name) { return root_.Command(std::move(name)); } + + Observable::ObserverHandlePtr RegisterObserver(ICommandRegistryObserver* observer) { return observable_->RegisterObserver(observer); } + void UnregisterObserver(ICommandRegistryObserver* observer) { observable_->UnregisterObserver(observer); } + + CommandNode& Command(std::string name) { + const bool existed = std::any_of(root_.children_.begin(), root_.children_.end(), [&](const auto& child){ return child->Matches(name); }); + CommandNode& result = root_.Command(name); + if (!existed) observable_->Registered({name}); + return result; + } + CommandRegistrationHandle RegisterCommand(std::string name) { if (name.empty()) return {}; for (const auto& child : root_.children_) if (child->Matches(name)) return {}; + std::vector path{name}; root_.Command(name); - return CommandRegistrationHandle(this, {std::move(name)}); + observable_->Registered(path); + return CommandRegistrationHandle(this, std::move(path)); } + bool UnregisterCommand(const std::vector& path) { if (path.empty()) return false; CommandNode* node = &root_; @@ -253,8 +269,11 @@ class CommandRegistry { if (!next) return false; node = next; } - return node->RemoveCommand(path.back()); + const bool removed = node->RemoveCommand(path.back()); + if (removed) observable_->Unregistered(path); + return removed; } + CommandRegistry& Use(Middleware middleware) { middleware_.push_back(std::move(middleware)); return *this; } CommandResult Invoke(const std::string& input) const { @@ -277,9 +296,7 @@ class CommandRegistry { const bool endsSpace = !input.empty() && std::isspace(static_cast(input.back())); std::string prefix; if (!endsSpace && !tokens.empty()) { prefix = tokens.back(); tokens.pop_back(); } const CommandNode* node = &root_; - for (const auto& token : tokens) { - const CommandNode* next = FindChild(*node, token); if (!next) return {}; node = next; - } + for (const auto& token : tokens) { const CommandNode* next = FindChild(*node, token); if (!next) return {}; node = next; } std::vector result; for (const auto& child : node->children_) if (!child->hidden_ && child->name_.compare(0, prefix.size(), prefix) == 0) result.push_back(child->name_); return result; @@ -314,6 +331,7 @@ class CommandRegistry { private: CommandNode root_; std::vector middleware_; + std::shared_ptr observable_; static const CommandNode* FindChild(const CommandNode& node, const std::string& name) { for (const auto& child : node.children_) if (child->Matches(name)) return child.get(); return nullptr; } static std::string HelpChildren(const CommandNode& node) { std::ostringstream os; for (const auto& c : node.children_) if (!c->hidden_) os << " " << c->name_ << (c->description_.empty() ? "" : "\t" + c->description_) << "\n"; return os.str(); } @@ -372,4 +390,4 @@ class CommandRegistry { inline void CommandRegistrationHandle::Reset() { if (registry_ != nullptr) { registry_->UnregisterCommand(path_); registry_ = nullptr; path_.clear(); } } -} // namespace ESPressio::Command \ No newline at end of file +} // namespace ESPressio::Command From 3446bd88664ca70814d42d4d0c564ce3950ee355 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 19:22:29 +0200 Subject: [PATCH 03/13] chore: bump Command to 0.3.0 --- library.json | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/library.json b/library.json index 7363ed7..456815c 100644 --- a/library.json +++ b/library.json @@ -1,7 +1,7 @@ { "name": "ESPressio-Command", "description": "Transport-neutral typed command definition, parsing, routing and invocation framework for ESP32 and C++17", - "keywords": "command,commands,cli,console,parser,router,dispatcher,espressio", + "keywords": "command,commands,cli,console,parser,router,dispatcher,observable,espressio", "authors": { "name": "Flowduino", "maintainer": true, @@ -17,8 +17,15 @@ "type": "git", "url": "https://github.com/Flowduino/ESPressio-Command.git" }, - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "frameworks": "*", - "platforms": "*" + "platforms": "*", + "dependencies": [ + { + "name": "Flowduino ESPressio-Observable", + "version": ">=3.0.1 <4.0.0", + "url": "https://github.com/Flowduino/ESPressio-Observable.git" + } + ] } From 0ba973ceb97ab879d2f5bf1d191f64487757f3d3 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 19:22:35 +0200 Subject: [PATCH 04/13] chore: update Command Arduino metadata --- library.properties | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/library.properties b/library.properties index cb35649..00fb19d 100644 --- a/library.properties +++ b/library.properties @@ -1,10 +1,11 @@ name=Flowduino ESPressio-Command -version=0.2.0 +version=0.3.0 author=Simon J. Stuart maintainer=Flowduino.com sentence=Transport-neutral typed command routing and invocation framework -paragraph=Defines hierarchical commands, typed positional and named parameters, validation, help, completion, middleware and callbacks independently of Serial, TCP, WebSocket or other input transports. +paragraph=Defines hierarchical commands, typed positional and named parameters, validation, help, completion, middleware, callbacks and observable command-registry lifecycle notifications independently of Serial, TCP, WebSocket or other input transports. category=Other url=https://github.com/Flowduino/ESPressio-Command architectures=* includes=ESPressio_Command.hpp,ESPressio_CommandFactory.hpp,ESPressio_CommandLine.hpp,ESPressio_Commands.hpp +depends=Flowduino ESPressio-Observable (>=3.0.1) From 0f60a9767c5afd3c9cd2aa21f69d1aa9b91deb55 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 19:22:40 +0200 Subject: [PATCH 05/13] chore: update Command component version --- component.mk | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/component.mk b/component.mk index a9adb15..bed2c5a 100644 --- a/component.mk +++ b/component.mk @@ -3,7 +3,7 @@ COMPONENT_SRCDIRS := src CXXFLAGS += -DESPRESSIO_COMMAND CXXFLAGS += -DESPRESSIO_COMMAND_VERSION_MAJOR=0 -CXXFLAGS += -DESPRESSIO_COMMAND_VERSION_MINOR=2 +CXXFLAGS += -DESPRESSIO_COMMAND_VERSION_MINOR=3 CXXFLAGS += -DESPRESSIO_COMMAND_VERSION_PATCH=0 -CXXFLAGS += -DESPRESSIO_COMMAND_VERSION_STRING=\"0.2.0\" +CXXFLAGS += -DESPRESSIO_COMMAND_VERSION_STRING=\"0.3.0\" CXXFLAGS += -std=gnu++17 From e11b8fd2aefa1cc66876e706a49e242271d512df Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 19:22:47 +0200 Subject: [PATCH 06/13] docs: add Command 0.3.0 changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fc4893..0128572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.3.0 + +- Added `ICommandRegistryObserver` and observer registration on `CommandRegistry`. +- Added notifications for root command registration and unregistration, including scoped `CommandRegistrationHandle` lifetime removal. +- Added ESPressio Observable as the registry-observer dependency. +- Added optional ESPressio Event bridge support through ESPressio Event 5.8.0. + ## 0.2.0 - Added ownership-safe `CommandRegistrationHandle` for scoped command registration. From ac68fd5fd0a61a8a7cdf689b5e43ae4e745ed9b3 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 19:34:03 +0200 Subject: [PATCH 07/13] test: wire Observable into Command host tests --- tests/CMakeLists.txt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 74875bd..4bbeac0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -5,7 +5,21 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS ON) +include(FetchContent) +FetchContent_Declare( + ESPressioObservable + GIT_REPOSITORY https://github.com/Flowduino/ESPressio-Observable.git + GIT_TAG 75fa06e5d56cf8f2673f441ae49e35e2017011cd + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(ESPressioObservable) +set(ESPRESSIO_OBSERVABLE_INCLUDE ${espressioobservable_SOURCE_DIR}/src) + enable_testing() add_executable(test_command test_command.cpp) -target_include_directories(test_command PRIVATE ../src) +target_include_directories(test_command PRIVATE ../src ${ESPRESSIO_OBSERVABLE_INCLUDE}) add_test(NAME ESPressioCommand COMMAND test_command) + +add_executable(test_observable test_observable.cpp) +target_include_directories(test_observable PRIVATE ../src ${ESPRESSIO_OBSERVABLE_INCLUDE}) +add_test(NAME ESPressioCommandObservable COMMAND test_observable) From 74ea89f9c8895cfaa272bae44d0eba5711a3dbc1 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 19:34:10 +0200 Subject: [PATCH 08/13] test: cover Command registry observations --- tests/test_observable.cpp | 55 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tests/test_observable.cpp diff --git a/tests/test_observable.cpp b/tests/test_observable.cpp new file mode 100644 index 0000000..01ddbc4 --- /dev/null +++ b/tests/test_observable.cpp @@ -0,0 +1,55 @@ +#include +#include +#include + +#include +#include + +using namespace ESPressio::Command; + +class Observer final : public ICommandRegistryObserver { +public: + int Registered = 0; + int Unregistered = 0; + std::vector LastPath; + + void OnCommandRegistered(const std::vector& path) override { + ++Registered; + LastPath = path; + } + + void OnCommandUnregistered(const std::vector& path) override { + ++Unregistered; + LastPath = path; + } +}; + +int main() { + CommandRegistry registry; + Observer observer; + auto observerHandle = registry.RegisterObserver(&observer); + assert(observerHandle); + + auto registration = registry.RegisterCommand("alpha"); + assert(registration.Active()); + assert(observer.Registered == 1); + assert(observer.LastPath.size() == 1 && observer.LastPath[0] == "alpha"); + + auto duplicate = registry.RegisterCommand("alpha"); + assert(!duplicate.Active()); + assert(observer.Registered == 1); + + registration.Reset(); + assert(observer.Unregistered == 1); + assert(observer.LastPath.size() == 1 && observer.LastPath[0] == "alpha"); + + registry.Command("beta"); + assert(observer.Registered == 2); + assert(registry.UnregisterCommand({"beta"})); + assert(observer.Unregistered == 2); + + observerHandle.reset(); + registry.Command("gamma"); + assert(observer.Registered == 2); + return 0; +} From 72d32fe0cd69d2464417e9d860223db78fdeecea Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 19:36:41 +0200 Subject: [PATCH 09/13] docs: document Command 0.3.0 observable registry lifecycle --- README.md | 463 ++++++++++++------------------------------------------ 1 file changed, 97 insertions(+), 366 deletions(-) diff --git a/README.md b/README.md index 261f0c8..857e338 100644 --- a/README.md +++ b/README.md @@ -1,105 +1,63 @@ # ESPressio Command -Transport-neutral, strongly typed Command definition, parsing, routing, -validation and invocation for the Flowduino ESPressio Development Platform. +Transport-neutral, strongly typed Command definition, parsing, routing, validation and invocation for the Flowduino ESPressio Development Platform. -ESPressio Command provides a common Command layer that separates **what an -application is being asked to do** from **how that request arrived**. Serial, -USB CDC, TCP, WebSocket, BLE, HTTP, test harnesses and programmatic callers can -therefore share the same Command tree, parameter definitions, validation and -callbacks without coupling application logic to a transport. +ESPressio Command separates **what an application is being asked to do** from **how the request arrived**. Serial, TCP, WebSocket, BLE, HTTP, test harnesses and programmatic callers can share one Command tree, parameter model, validation layer and execution callbacks. -## Latest Stable Version +## Current Development Version -ESPressio Command is currently **0.2.0 (pre-release)**. +This branch targets **ESPressio Command 0.3.0 (pre-release)**. -This is the initial pre-release of the library. For release-by-release history, -see [CHANGELOG.md](CHANGELOG.md). +0.3.0 adds Observable coverage for the lifecycle of dynamically registered command roots while preserving the existing execution callback, middleware, before/after and parsing APIs. -## Compatibility - -ESPressio Command targets **C++17** and is designed primarily for the **ESP32 -family under Arduino-ESP32** as part of the ESPressio Development Platform. - -The Command core is deliberately transport-neutral and does not directly depend -on Arduino `Stream`, `Print`, ESPressio Serial, ESPressio Event, a network -stack, or any other ESPressio component library. - -Host-side tests are also provided so that the transport-neutral core can be -validated with a conventional C++17 toolchain. - -Compatibility should still be verified against the exact compiler, -Arduino-ESP32 version and ESP32 target used by the consuming application. - -## ESPressio Development Platform - -The **ESPressio Development Platform** is a collection of discrete, composable -component libraries developed around a common design ethos. - -The principal objectives are: +See [CHANGELOG.md](CHANGELOG.md) for release history. -- **Light-weight** — components should strive to minimise memory consumption - and operational overhead without sacrificing clarity or correctness. -- **Ease of Use** — ESPressio components provide developer-friendly, strongly - typed abstractions over lower-level procedural facilities. -- **Object-Oriented** — a type for everything, and everything in a type. -- **SOLID** — to the maximum extent practical within C++, Arduino, FreeRTOS and - microcontroller constraints: - - **Single Responsibility Principle (SRP)** — keep components small and - focused. - - **Open/Closed Principle (OCP)** — prefer extension without modification. - - **Liskov Substitution Principle (LSP)** — derived implementations should - remain substitutable for their abstractions. - - **Interface Segregation Principle (ISP)** — prefer focused, - client-specific interfaces. - - **Dependency Inversion Principle (DIP)** — depend upon abstractions rather - than concrete implementations. - -ESPressio Command follows these principles by treating a Command invocation as -a transport-independent application contract. Transport adapters depend on the -Command abstraction; the Command core does not depend on the adapters. - -## License +## Compatibility -ESPressio and its component libraries are licensed under the **Apache License -2.0**. +ESPressio Command targets C++17 and is primarily intended for the ESP32/Arduino-ESP32 ecosystem. The command core remains transport-neutral and does not directly depend on Serial, Event or a network stack. -See [LICENSE](LICENSE) for details. +## ESPressio dependencies -## ESPressio Library Dependencies +Command 0.3.0 requires: -ESPressio is designed as a modular ecosystem of independently useful libraries, -with required dependencies kept explicit and optional integrations introduced -only when the corresponding functionality is selected. +- **ESPressio Observable >= 3.0.1 and < 4.0.0**. -For a complete overview of required and opt-in relationships, see: +ESPressio Event remains optional. Applications that want Command registry lifecycle observations represented as Events can select **ESPressio Event 5.8.0+** and use `ESPressio_CommandRegistryEventBridge.hpp`. -**[ESPressio Library Dependency Chart](ESPRESSIO_DEPENDENCY_CHART.md)** +```text +ESPressio Observable + | + v +ESPressio Command -In the dependency chart: +ESPressio Command ---- optional observer source ----> ESPressio Event bridge +``` -- **Solid relationships** represent required ESPressio dependencies. -- **Dashed relationships** represent opt-in dependencies introduced only when - the corresponding feature, integration, type, or header is used. +See [ESPRESSIO_DEPENDENCY_CHART.md](ESPRESSIO_DEPENDENCY_CHART.md) for the ecosystem relationship view. -### Required ESPressio dependencies +## PlatformIO -**None.** +```ini +lib_deps = + flowduino/ESPressio-Command@^0.3.0 + flowduino/ESPressio-Observable@^3.0.1 +``` -ESPressio Command is intentionally dependency-free within the ESPressio -ecosystem. Future Serial, Event, networking, Serializable or other integrations -should depend on Command or be provided as opt-in adapters; they must not become -mandatory dependencies of the Command core. +For deliberate feature-branch consumption before tagging: -## Namespace +```ini +lib_deps = + https://github.com/Flowduino/ESPressio-Command.git#feature/observable-callback-coverage + flowduino/ESPressio-Observable@^3.0.1 +``` -The Command API resides beneath: +## Namespace and principal types ```cpp ESPressio::Command ``` -The principal public types are: +Principal public types include: - `CommandRegistry` — owns and resolves the Command tree. - `CommandNode` — describes a Command or Command group. @@ -107,349 +65,122 @@ The principal public types are: - `CommandContext` — exposes resolved values to a Command callback. - `CommandInvocation` — transport-neutral structured invocation. - `CommandResult` — success/error result returned by Command execution. -- `TextCommandParser` — converts textual Command lines into tokens. -- `CommandLine` — incrementally consumes character/buffer input. -- `CommandFactory` — convenient facade for Command registration. - -## PlatformIO - -You can add the published library to a PlatformIO project with: - -```ini -lib_deps = - flowduino/ESPressio-Command@^0.2.0 -``` - -Until a release/tag is published, or when deliberately consuming the latest -integration sources, use: - -```ini -lib_deps = - https://github.com/Flowduino/ESPressio-Command.git -``` - -The Git source tracks the latest commits on the repository and may therefore be -more volatile than a tagged release. +- `TextCommandParser` — textual tokenisation. +- `CommandLine` — incremental input adapter. +- `CommandRegistrationHandle` — ownership-safe scoped registration lifetime. +- `ICommandRegistryObserver` — synchronous registration-lifecycle observer introduced in 0.3.0. -## Why a separate Command library? +## Command trees -A **Command** expresses intent: **do something**. - -An **Event** expresses a fact: **something happened**. - -Keeping these concepts separate allows application code to expose operations -without embedding Serial, networking, protocol or UI concerns into those -operations. - -```text -Serial / USB CDC ----+ -TCP / WebSocket -----+ -BLE / HTTP ----------+--> CommandInvocation --> CommandRegistry --> callback -Programmatic --------+ -Test harness --------+ -``` - -Text input is therefore only one possible adapter. The same registered Command -can be invoked from a parsed line, a structured request or another application -component. - -## Command Trees - -Commands are organised hierarchically. A parent can represent a namespace or -operation group while child nodes provide increasingly specific actions. +Commands are hierarchical: ```cpp -#include +#include using namespace ESPressio::Command; -auto& commands = CommandRegistry::GetInstance(); +auto& registry = CommandRegistry::GetInstance(); -auto& write = commands.Command("gpio") +auto& write = registry.Command("gpio") .Description("GPIO operations") .Command("write") .Description("Set a GPIO output value"); -write.Parameter("pin") - .Description("GPIO pin") - .Range(0, 48); - -write.Parameter("state") - .Description("Desired pin state"); +write.Parameter("pin").Range(0, 48); +write.Parameter("state"); write.OnExecute([](const CommandContext& context) { const int pin = context.Get("pin"); const bool state = context.Get("state"); - - // digitalWrite(pin, state ? HIGH : LOW); - + (void)pin; + (void)state; return CommandResult::Ok("GPIO updated"); }); ``` -All of the following resolve to the same callback: - -```text -gpio write 2 high -gpio write --pin 2 --state high -gpio write --pin=2 --state=high -``` - -This makes the Command definition the authoritative contract rather than any -particular textual syntax. - -## Parameters - -Parameters can be: - -- strongly typed as string, boolean, signed integer, unsigned integer or - floating point; -- positional, named-only, or supplied by name; -- required or optional; -- assigned default values; -- given aliases; -- range constrained; -- constrained to a set of permitted values; and -- checked by a custom validator. - -For example: - -```cpp -auto& mode = commands.Command("gpio").Command("mode"); - -mode.Parameter("pin") - .Range(0, 48); - -mode.Parameter("mode", ParameterKind::Enumeration) - .OneOf({"in", "out", "pullup", "pulldown"}); -``` - -Resolved values are exposed through `CommandContext` and can be requested in -the desired C++ type: - -```cpp -const int pin = context.Get("pin"); -const bool state = context.Get("state"); -``` - -Validation occurs before the Command callback is executed, keeping parsing and -input validation out of application logic. - -## Automatic Help - -Help is generated from the same metadata used to define and resolve Commands: - -```text -help -help gpio -help gpio write -``` - -Help can also be generated programmatically: - -```cpp -auto text = commands.Help({"gpio", "write"}); -``` - -Descriptions, parameters, required/optional state and defaults therefore remain -aligned with the executable Command definition. - -## Completion and Typo Suggestions - -Registered Command metadata can also be used for completion: - -```cpp -auto matches = commands.Complete("gpio w"); -``` - -Unknown Command names are compared with registered siblings and a nearby -Command is suggested where appropriate. Hidden Commands are omitted from -completion results. +Textual and structured adapters ultimately invoke the same registry contract. -## Structured Invocation +## Parameters and execution -Text parsing is an adapter rather than the core Command contract. Other input -mechanisms can invoke the registry directly without manufacturing a textual -Command line: - -```cpp -CommandInvocation invocation; -invocation.path = {"gpio", "write"}; -invocation.named["pin"] = "2"; -invocation.named["state"] = "high"; - -auto result = commands.Invoke(invocation); -``` - -This is the intended integration point for Serial adapters, HTTP endpoints, -WebSocket messages, BLE services, RPC mechanisms, automated tests and other -structured callers. - -## Middleware and Interception - -Cross-cutting behaviour can wrap every invocation: - -```cpp -commands.Use([](const CommandInvocation& invocation, const auto& next) { - // Authorization, audit, rate limiting, tracing, etc. - return next(); -}); -``` +The existing Command model continues to support: -Individual Commands can also register `Before(...)` and `After(...)` callbacks. +- string, boolean, signed/unsigned integer and floating-point conversion; +- positional and GNU-style named parameters; +- required/optional/defaulted parameters; +- aliases; +- numeric ranges; +- enumerated choices; +- custom validators; +- command aliases, hidden commands and deprecation metadata; +- automatic help and completion; +- global middleware; and +- per-command before/execute/after callbacks. -These extension points allow policy, diagnostics and integration behaviour to -be layered around Command execution without coupling those concerns to the -Command callback itself. +These execution hooks remain **callbacks** by design. They represent command behaviour and invocation control, not passive observation. -## Incremental Text Input +## Scoped registration -`CommandLine` accepts characters or buffers and submits complete lines to a -registry. It deliberately knows nothing about Serial itself: +Dynamic integrations can own a root registration using `CommandRegistrationHandle`: ```cpp -CommandLine input(commands); +auto registration = registry.RegisterCommand("diagnostics"); -input.OnResult([](const CommandResult& result) { - // Send result.message to whichever output transport owns this input. -}); +// ...configure/use diagnostics subtree... -input.Feed(receivedCharacter); +registration.Reset(); // unregisters the owned root ``` -A Serial or USB CDC integration can therefore feed received bytes into the -Command layer while retaining complete ownership of the underlying stream, -connection and output formatting. +A handle also unregisters its owned registration when its lifetime ends. -## Aliases, Visibility and Deprecation +## Observable registry lifecycle -Commands can expose aliases without duplicating callbacks: +0.3.0 makes externally meaningful registry topology changes observable: ```cpp -commands.Command("diagnostics") - .Alias("diag") - .Description("Diagnostic commands"); -``` +class RegistryObserver final : + public ESPressio::Command::ICommandRegistryObserver { +public: + void OnCommandRegistered( + const std::vector& path + ) override { + // Passive observation / diagnostics / UI refresh. + } -Commands can also be marked as deprecated: + void OnCommandUnregistered( + const std::vector& path + ) override { + // Registration lifetime ended. + } +}; -```cpp -commands.Command("old-command") - .Deprecated("Use 'new-command' instead"); +RegistryObserver observer; +auto observerHandle = registry.RegisterObserver(&observer); ``` -Hidden Commands remain resolvable but are omitted from generated help and -completion. - -## Quoting and Escaping - -The text parser supports whitespace-separated arguments, single-quoted values, -double-quoted values and backslash escaping: - -```text -system label "Main Controller" -system label 'Bench Unit' -``` +Notifications are emitted for newly created root commands and successful unregistration, including scoped `CommandRegistrationHandle` cleanup. Duplicate registration attempts that do not change the registry do not emit a lifecycle notification. -This keeps ordinary console usage convenient without making textual parsing a -requirement for structured callers. +Invocation itself deliberately remains on the existing callback/middleware path rather than being duplicated as an Observable surface. -## Command Results +## Optional Event bridge -Command callbacks return `CommandResult`, providing a transport-neutral success -state, numeric result code and optional message: +With ESPressio Event 5.8.0+: ```cpp -return CommandResult::Ok("GPIO updated"); -``` - -or: +#include -```cpp -return CommandResult::Error("GPIO update failed", 5); +ESPressio::Event::CommandRegistryEventBridge::GetInstance().Initialize(registry); ``` -The caller or adapter decides how that result is represented to its consumer. -A Serial console may print the message, while an HTTP adapter might map the -result into a structured response. - -## Design Principles - -ESPressio Command is intentionally built around a small number of architectural -rules: - -1. **Commands describe intent, not transport.** -2. **Command definitions are the authoritative source of metadata and - validation.** -3. **Application callbacks receive validated, typed values.** -4. **Text parsing is an adapter, not the invocation model.** -5. **Transport and protocol integrations belong outside the core.** -6. **Cross-cutting behaviour should be implemented through middleware or - focused hooks rather than embedded in application callbacks.** -7. **The core remains independently useful and dependency-free.** - -## Examples - -The repository includes examples beneath [`examples/`](examples/) demonstrating -Command registration and invocation in an Arduino/ESP32 application. - -A typical application defines its Command tree during initialization and then -feeds invocations from whichever transport or application surface owns the -interaction. +The bridge converts registration/unregistration observations into asynchronous `CommandRegisteredEvent` and `CommandUnregisteredEvent` instances. Command does not depend on Event. ## Testing -Host-side tests are provided beneath [`tests/`](tests/). - -They exercise the transport-neutral Command implementation independently of -Arduino hardware. This keeps parsing, resolution, validation and invocation -behaviour testable with a conventional C++17 toolchain while embedded examples -validate intended ESP32 integration usage. - -## Future Integration Direction +Host tests cover command parsing/execution plus observer registration lifetime and registration/unregistration notification semantics. -ESPressio Command is intended to become the common invocation layer for: +## Design principle -- Serial and USB consoles; -- TCP, WebSocket and BLE Command surfaces; -- HTTP/RPC gateways; -- structured programmatic invocation; -- Command discovery and schemas; -- authorization and permissions; -- auditing and diagnostics; -- cancellation/progress for asynchronous operations; -- remote Command invocation; -- JSON/Serializable argument adapters; and -- Event bridges for Command completion/result Events. - -These integrations should remain **opt-in**. The dependency direction is -important: - -```text -Serial adapter --------+ -Network adapter -------+ -Serializable adapter --+--> ESPressio Command -Event bridge ----------+ -``` - -The Command core must remain transport-neutral and independently usable. - -## Contributing - -Issues and contributions are welcome through the ESPressio Command GitHub -repository. Changes should preserve the library's transport-neutral core, -C++17 compatibility and ESPressio design principles. - -Where practical, behavioural changes should include corresponding tests and -examples or documentation updates. - -## Changelog - -See [CHANGELOG.md](CHANGELOG.md) for release history and notable changes. +A Command expresses intent: **do something**. An Event expresses a fact: **something happened**. Observable lifecycle support therefore reports changes to the Command registry without turning command execution itself into an Event or replacing its callbacks. ## License -ESPressio and its component libraries are licensed under the **Apache License -2.0**. - -See [LICENSE](LICENSE) for details. +Apache License 2.0. See [LICENSE](LICENSE). From d3fc920bac743b397ebf3c640abddc193c75db98 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 19:40:38 +0200 Subject: [PATCH 10/13] test: pin Command host tests to Observable 3.0.1 --- tests/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4bbeac0..98a4001 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -9,7 +9,7 @@ include(FetchContent) FetchContent_Declare( ESPressioObservable GIT_REPOSITORY https://github.com/Flowduino/ESPressio-Observable.git - GIT_TAG 75fa06e5d56cf8f2673f441ae49e35e2017011cd + GIT_TAG 3.0.1 GIT_SHALLOW TRUE ) FetchContent_MakeAvailable(ESPressioObservable) From 19c73026a412a897967b9f147208453211d03766 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 19:42:36 +0200 Subject: [PATCH 11/13] docs: preserve Command long-form docs with 0.3.0 development update --- README.md | 491 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 402 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 857e338..6f67732 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,120 @@ # ESPressio Command -Transport-neutral, strongly typed Command definition, parsing, routing, validation and invocation for the Flowduino ESPressio Development Platform. +Transport-neutral, strongly typed Command definition, parsing, routing, +validation and invocation for the Flowduino ESPressio Development Platform. -ESPressio Command separates **what an application is being asked to do** from **how the request arrived**. Serial, TCP, WebSocket, BLE, HTTP, test harnesses and programmatic callers can share one Command tree, parameter model, validation layer and execution callbacks. +ESPressio Command provides a common Command layer that separates **what an +application is being asked to do** from **how that request arrived**. Serial, +USB CDC, TCP, WebSocket, BLE, HTTP, test harnesses and programmatic callers can +therefore share the same Command tree, parameter definitions, validation and +callbacks without coupling application logic to a transport. -## Current Development Version +## 0.3.0 Development Update — Observable Callback Coverage -This branch targets **ESPressio Command 0.3.0 (pre-release)**. +The `feature/observable-callback-coverage` branch targets **ESPressio Command 0.3.0**. The stable/pre-release information below remains the 0.2.0 documentation until 0.3.0 is released. -0.3.0 adds Observable coverage for the lifecycle of dynamically registered command roots while preserving the existing execution callback, middleware, before/after and parsing APIs. +Command 0.3.0 adds a required dependency on **ESPressio Observable >= 3.0.1 and < 4.0.0** and introduces `ICommandRegistryObserver`. `CommandRegistry` now reports root command registration and successful unregistration, including scoped `CommandRegistrationHandle` cleanup. Command invocation itself deliberately remains on the existing callbacks, middleware, `Before(...)` and `After(...)` hooks rather than being duplicated as Observable traffic. -See [CHANGELOG.md](CHANGELOG.md) for release history. +ESPressio Event remains **optional**. ESPressio Event 5.8.0 provides `CommandRegistryEventBridge`, which converts registry lifecycle observations into asynchronous `CommandRegisteredEvent` and `CommandUnregisteredEvent` instances without making Event a Command dependency. + +Development-branch PlatformIO dependencies are: + +```ini +lib_deps = + https://github.com/Flowduino/ESPressio-Command.git#feature/observable-callback-coverage + flowduino/ESPressio-Observable@^3.0.1 +``` + +The host tests include dedicated registry-observer lifecycle coverage. See [CHANGELOG.md](CHANGELOG.md) for the complete 0.3.0 change list. + +## Latest Stable Version + +ESPressio Command is currently **0.2.0 (pre-release)**. + +This is the initial pre-release of the library. For release-by-release history, +see [CHANGELOG.md](CHANGELOG.md). ## Compatibility -ESPressio Command targets C++17 and is primarily intended for the ESP32/Arduino-ESP32 ecosystem. The command core remains transport-neutral and does not directly depend on Serial, Event or a network stack. +ESPressio Command targets **C++17** and is designed primarily for the **ESP32 +family under Arduino-ESP32** as part of the ESPressio Development Platform. -## ESPressio dependencies +The Command core is deliberately transport-neutral and does not directly depend +on Arduino `Stream`, `Print`, ESPressio Serial, ESPressio Event, a network +stack, or any other ESPressio component library. Beginning with the 0.3.0 development generation it does require ESPressio Observable 3.x for its registry lifecycle surface. -Command 0.3.0 requires: +Host-side tests are also provided so that the transport-neutral core can be +validated with a conventional C++17 toolchain. -- **ESPressio Observable >= 3.0.1 and < 4.0.0**. +Compatibility should still be verified against the exact compiler, +Arduino-ESP32 version and ESP32 target used by the consuming application. -ESPressio Event remains optional. Applications that want Command registry lifecycle observations represented as Events can select **ESPressio Event 5.8.0+** and use `ESPressio_CommandRegistryEventBridge.hpp`. +## ESPressio Development Platform -```text -ESPressio Observable - | - v -ESPressio Command +The **ESPressio Development Platform** is a collection of discrete, composable +component libraries developed around a common design ethos. -ESPressio Command ---- optional observer source ----> ESPressio Event bridge -``` +The principal objectives are: -See [ESPRESSIO_DEPENDENCY_CHART.md](ESPRESSIO_DEPENDENCY_CHART.md) for the ecosystem relationship view. +- **Light-weight** — components should strive to minimise memory consumption + and operational overhead without sacrificing clarity or correctness. +- **Ease of Use** — ESPressio components provide developer-friendly, strongly + typed abstractions over lower-level procedural facilities. +- **Object-Oriented** — a type for everything, and everything in a type. +- **SOLID** — to the maximum extent practical within C++, Arduino, FreeRTOS and + microcontroller constraints: + - **Single Responsibility Principle (SRP)** — keep components small and + focused. + - **Open/Closed Principle (OCP)** — prefer extension without modification. + - **Liskov Substitution Principle (LSP)** — derived implementations should + remain substitutable for their abstractions. + - **Interface Segregation Principle (ISP)** — prefer focused, + client-specific interfaces. + - **Dependency Inversion Principle (DIP)** — depend upon abstractions rather + than concrete implementations. -## PlatformIO +ESPressio Command follows these principles by treating a Command invocation as +a transport-independent application contract. Transport adapters depend on the +Command abstraction; the Command core does not depend on the adapters. -```ini -lib_deps = - flowduino/ESPressio-Command@^0.3.0 - flowduino/ESPressio-Observable@^3.0.1 -``` +## License -For deliberate feature-branch consumption before tagging: +ESPressio and its component libraries are licensed under the **Apache License +2.0**. -```ini -lib_deps = - https://github.com/Flowduino/ESPressio-Command.git#feature/observable-callback-coverage - flowduino/ESPressio-Observable@^3.0.1 -``` +See [LICENSE](LICENSE) for details. + +## ESPressio Library Dependencies + +ESPressio is designed as a modular ecosystem of independently useful libraries, +with required dependencies kept explicit and optional integrations introduced +only when the corresponding functionality is selected. + +For a complete overview of required and opt-in relationships, see: -## Namespace and principal types +**[ESPressio Library Dependency Chart](ESPRESSIO_DEPENDENCY_CHART.md)** + +In the dependency chart: + +- **Solid relationships** represent required ESPressio dependencies. +- **Dashed relationships** represent opt-in dependencies introduced only when + the corresponding feature, integration, type, or header is used. + +### Required ESPressio dependencies + +The stable 0.2.0 pre-release has no ESPressio dependency. **The 0.3.0 development branch requires ESPressio Observable >= 3.0.1 and < 4.0.0.** + +Serial, Event, networking, Serializable and other integrations should depend on Command or be provided as opt-in adapters; they must not become mandatory dependencies of the Command core. Event remains opt-in even though 5.8.0 provides a Command registry Event bridge. + +## Namespace + +The Command API resides beneath: ```cpp ESPressio::Command ``` -Principal public types include: +The principal public types are: - `CommandRegistry` — owns and resolves the Command tree. - `CommandNode` — describes a Command or Command group. @@ -65,122 +122,378 @@ Principal public types include: - `CommandContext` — exposes resolved values to a Command callback. - `CommandInvocation` — transport-neutral structured invocation. - `CommandResult` — success/error result returned by Command execution. -- `TextCommandParser` — textual tokenisation. -- `CommandLine` — incremental input adapter. -- `CommandRegistrationHandle` — ownership-safe scoped registration lifetime. -- `ICommandRegistryObserver` — synchronous registration-lifecycle observer introduced in 0.3.0. +- `TextCommandParser` — converts textual Command lines into tokens. +- `CommandLine` — incrementally consumes character/buffer input. +- `CommandFactory` — convenient facade for Command registration. +- `CommandRegistrationHandle` — ownership-safe scoped dynamic registration. +- `ICommandRegistryObserver` — 0.3.0 registry lifecycle observer. + +## PlatformIO -## Command trees +For the stable/pre-release 0.2.0 generation: -Commands are hierarchical: +```ini +lib_deps = + flowduino/ESPressio-Command@^0.2.0 +``` + +For 0.3.0, consume ESPressio Observable 3.x as shown in the development update above. + +Until a release/tag is published, or when deliberately consuming the latest +integration sources, use: + +```ini +lib_deps = + https://github.com/Flowduino/ESPressio-Command.git +``` + +The Git source tracks the latest commits on the repository and may therefore be +more volatile than a tagged release. + +## Why a separate Command library? + +A **Command** expresses intent: **do something**. + +An **Event** expresses a fact: **something happened**. + +Keeping these concepts separate allows application code to expose operations +without embedding Serial, networking, protocol or UI concerns into those +operations. + +```text +Serial / USB CDC ----+ +TCP / WebSocket -----+ +BLE / HTTP ----------+--> CommandInvocation --> CommandRegistry --> callback +Programmatic --------+ +Test harness --------+ +``` + +Text input is therefore only one possible adapter. The same registered Command +can be invoked from a parsed line, a structured request or another application +component. + +## Command Trees + +Commands are organised hierarchically. A parent can represent a namespace or +operation group while child nodes provide increasingly specific actions. ```cpp -#include +#include using namespace ESPressio::Command; -auto& registry = CommandRegistry::GetInstance(); +auto& commands = CommandRegistry::GetInstance(); -auto& write = registry.Command("gpio") +auto& write = commands.Command("gpio") .Description("GPIO operations") .Command("write") .Description("Set a GPIO output value"); -write.Parameter("pin").Range(0, 48); -write.Parameter("state"); +write.Parameter("pin") + .Description("GPIO pin") + .Range(0, 48); + +write.Parameter("state") + .Description("Desired pin state"); write.OnExecute([](const CommandContext& context) { const int pin = context.Get("pin"); const bool state = context.Get("state"); - (void)pin; - (void)state; + + // digitalWrite(pin, state ? HIGH : LOW); + return CommandResult::Ok("GPIO updated"); }); ``` -Textual and structured adapters ultimately invoke the same registry contract. +All of the following resolve to the same callback: -## Parameters and execution +```text +gpio write 2 high +gpio write --pin 2 --state high +gpio write --pin=2 --state=high +``` -The existing Command model continues to support: +This makes the Command definition the authoritative contract rather than any +particular textual syntax. -- string, boolean, signed/unsigned integer and floating-point conversion; -- positional and GNU-style named parameters; -- required/optional/defaulted parameters; -- aliases; -- numeric ranges; -- enumerated choices; -- custom validators; -- command aliases, hidden commands and deprecation metadata; -- automatic help and completion; -- global middleware; and -- per-command before/execute/after callbacks. +## Parameters -These execution hooks remain **callbacks** by design. They represent command behaviour and invocation control, not passive observation. +Parameters can be: -## Scoped registration +- strongly typed as string, boolean, signed integer, unsigned integer or + floating point; +- positional, named-only, or supplied by name; +- required or optional; +- assigned default values; +- given aliases; +- range constrained; +- constrained to a set of permitted values; and +- checked by a custom validator. -Dynamic integrations can own a root registration using `CommandRegistrationHandle`: +For example: ```cpp -auto registration = registry.RegisterCommand("diagnostics"); +auto& mode = commands.Command("gpio").Command("mode"); + +mode.Parameter("pin") + .Range(0, 48); + +mode.Parameter("mode", ParameterKind::Enumeration) + .OneOf({"in", "out", "pullup", "pulldown"}); +``` -// ...configure/use diagnostics subtree... +Resolved values are exposed through `CommandContext` and can be requested in +the desired C++ type: -registration.Reset(); // unregisters the owned root +```cpp +const int pin = context.Get("pin"); +const bool state = context.Get("state"); ``` -A handle also unregisters its owned registration when its lifetime ends. +Validation occurs before the Command callback is executed, keeping parsing and +input validation out of application logic. + +## Automatic Help -## Observable registry lifecycle +Help is generated from the same metadata used to define and resolve Commands: -0.3.0 makes externally meaningful registry topology changes observable: +```text +help +help gpio +help gpio write +``` + +Help can also be generated programmatically: + +```cpp +auto text = commands.Help({"gpio", "write"}); +``` + +Descriptions, parameters, required/optional state and defaults therefore remain +aligned with the executable Command definition. + +## Completion and Typo Suggestions + +Registered Command metadata can also be used for completion: + +```cpp +auto matches = commands.Complete("gpio w"); +``` + +Unknown Command names are compared with registered siblings and a nearby +Command is suggested where appropriate. Hidden Commands are omitted from +completion results. + +## Structured Invocation + +Text parsing is an adapter rather than the core Command contract. Other input +mechanisms can invoke the registry directly without manufacturing a textual +Command line: + +```cpp +CommandInvocation invocation; +invocation.path = {"gpio", "write"}; +invocation.named["pin"] = "2"; +invocation.named["state"] = "high"; + +auto result = commands.Invoke(invocation); +``` + +This is the intended integration point for Serial adapters, HTTP endpoints, +WebSocket messages, BLE services, RPC mechanisms, automated tests and other +structured callers. + +## Middleware and Interception + +Cross-cutting behaviour can wrap every invocation: + +```cpp +commands.Use([](const CommandInvocation& invocation, const auto& next) { + // Authorization, audit, rate limiting, tracing, etc. + return next(); +}); +``` + +Individual Commands can also register `Before(...)` and `After(...)` callbacks. + +These extension points allow policy, diagnostics and integration behaviour to +be layered around Command execution without coupling those concerns to the +Command callback itself. + +## Observable Registry Lifecycle (0.3.0) + +Registry topology changes can now be observed without changing command execution semantics: ```cpp class RegistryObserver final : public ESPressio::Command::ICommandRegistryObserver { public: - void OnCommandRegistered( - const std::vector& path - ) override { - // Passive observation / diagnostics / UI refresh. + void OnCommandRegistered(const std::vector& path) override { + // Passive diagnostics / discovery refresh. } - void OnCommandUnregistered( - const std::vector& path - ) override { - // Registration lifetime ended. + void OnCommandUnregistered(const std::vector& path) override { + // Owned registration lifetime ended. } }; RegistryObserver observer; -auto observerHandle = registry.RegisterObserver(&observer); +auto observerHandle = commands.RegisterObserver(&observer); ``` -Notifications are emitted for newly created root commands and successful unregistration, including scoped `CommandRegistrationHandle` cleanup. Duplicate registration attempts that do not change the registry do not emit a lifecycle notification. +New root creation and successful root removal emit notifications. Duplicate registration attempts that do not change the tree do not emit. `CommandRegistrationHandle::Reset()` and handle destruction flow through the same successful-unregistration path. + +With ESPressio Event 5.8.0 selected, `CommandRegistryEventBridge` can convert these facts into asynchronous Events. Event remains an optional downstream adapter. + +## Incremental Text Input + +`CommandLine` accepts characters or buffers and submits complete lines to a +registry. It deliberately knows nothing about Serial itself: -Invocation itself deliberately remains on the existing callback/middleware path rather than being duplicated as an Observable surface. +```cpp +CommandLine input(commands); + +input.OnResult([](const CommandResult& result) { + // Send result.message to whichever output transport owns this input. +}); + +input.Feed(receivedCharacter); +``` -## Optional Event bridge +A Serial or USB CDC integration can therefore feed received bytes into the +Command layer while retaining complete ownership of the underlying stream, +connection and output formatting. -With ESPressio Event 5.8.0+: +## Aliases, Visibility and Deprecation + +Commands can expose aliases without duplicating callbacks: ```cpp -#include +commands.Command("diagnostics") + .Alias("diag") + .Description("Diagnostic commands"); +``` -ESPressio::Event::CommandRegistryEventBridge::GetInstance().Initialize(registry); +Commands can also be marked as deprecated: + +```cpp +commands.Command("old-command") + .Deprecated("Use 'new-command' instead"); ``` -The bridge converts registration/unregistration observations into asynchronous `CommandRegisteredEvent` and `CommandUnregisteredEvent` instances. Command does not depend on Event. +Hidden Commands remain resolvable but are omitted from generated help and +completion. + +## Quoting and Escaping + +The text parser supports whitespace-separated arguments, single-quoted values, +double-quoted values and backslash escaping: + +```text +system label "Main Controller" +system label 'Bench Unit' +``` + +This keeps ordinary console usage convenient without making textual parsing a +requirement for structured callers. + +## Command Results + +Command callbacks return `CommandResult`, providing a transport-neutral success +state, numeric result code and optional message: + +```cpp +return CommandResult::Ok("GPIO updated"); +``` + +or: + +```cpp +return CommandResult::Error("GPIO update failed", 5); +``` + +The caller or adapter decides how that result is represented to its consumer. +A Serial console may print the message, while an HTTP adapter might map the +result into a structured response. + +## Design Principles + +ESPressio Command is intentionally built around a small number of architectural +rules: + +1. **Commands describe intent, not transport.** +2. **Command definitions are the authoritative source of metadata and + validation.** +3. **Application callbacks receive validated, typed values.** +4. **Text parsing is an adapter, not the invocation model.** +5. **Transport and protocol integrations belong outside the core.** +6. **Cross-cutting behaviour should be implemented through middleware or + focused hooks rather than embedded in application callbacks.** +7. **The core remains independently useful; from 0.3.0 its only required ESPressio dependency is Observable.** + +## Examples + +The repository includes examples beneath [`examples/`](examples/) demonstrating +Command registration and invocation in an Arduino/ESP32 application. + +A typical application defines its Command tree during initialization and then +feeds invocations from whichever transport or application surface owns the +interaction. ## Testing -Host tests cover command parsing/execution plus observer registration lifetime and registration/unregistration notification semantics. +Host-side tests are provided beneath [`tests/`](tests/). + +They exercise the transport-neutral Command implementation independently of +Arduino hardware. This keeps parsing, resolution, validation and invocation +behaviour testable with a conventional C++17 toolchain while embedded examples +validate intended ESP32 integration usage. The 0.3.0 generation also validates registry-observer registration lifetime and notification semantics. -## Design principle +## Future Integration Direction -A Command expresses intent: **do something**. An Event expresses a fact: **something happened**. Observable lifecycle support therefore reports changes to the Command registry without turning command execution itself into an Event or replacing its callbacks. +ESPressio Command is intended to become the common invocation layer for: + +- Serial and USB consoles; +- TCP, WebSocket and BLE Command surfaces; +- HTTP/RPC gateways; +- structured programmatic invocation; +- Command discovery and schemas; +- authorization and permissions; +- auditing and diagnostics; +- cancellation/progress for asynchronous operations; +- remote Command invocation; +- JSON/Serializable argument adapters; and +- Event bridges for Command lifecycle/completion/result Events where those asynchronous representations are justified. + +These integrations should remain **opt-in**. The dependency direction is +important: + +```text +Serial adapter --------+ +Network adapter -------+ +Serializable adapter --+--> ESPressio Command --> ESPressio Observable +Event bridge ----------+ +``` + +The Command core must remain transport-neutral and independently usable. + +## Contributing + +Issues and contributions are welcome through the ESPressio Command GitHub +repository. Changes should preserve the library's transport-neutral core, +C++17 compatibility and ESPressio design principles. + +Where practical, behavioural changes should include corresponding tests and +examples or documentation updates. + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for release history and notable changes. ## License -Apache License 2.0. See [LICENSE](LICENSE). +ESPressio and its component libraries are licensed under the **Apache License +2.0**. + +See [LICENSE](LICENSE) for details. From 9daf7057a4c961718d3ef1c50185efc12c697219 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 20:30:59 +0200 Subject: [PATCH 12/13] test: fetch Observable headers without configuring ESP-IDF component --- tests/CMakeLists.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 98a4001..33bd48f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -12,7 +12,10 @@ FetchContent_Declare( GIT_TAG 3.0.1 GIT_SHALLOW TRUE ) -FetchContent_MakeAvailable(ESPressioObservable) +FetchContent_GetProperties(ESPressioObservable) +if(NOT espressioobservable_POPULATED) + FetchContent_Populate(ESPressioObservable) +endif() set(ESPRESSIO_OBSERVABLE_INCLUDE ${espressioobservable_SOURCE_DIR}/src) enable_testing() From d2ef6c9c744690ca204284665e1a629ffd801b17 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 20:31:16 +0200 Subject: [PATCH 13/13] ci: validate host and ESP32 Observable-backed Command builds --- .github/workflows/tests.yml | 43 +++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3838482..bda1efa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,12 @@ name: Tests on: push: + branches: + - main + - feature/observable-callback-coverage pull_request: + branches: + - main jobs: host-tests: @@ -15,3 +20,41 @@ jobs: run: cmake --build build --parallel - name: Test run: ctest --test-dir build --output-on-failure + + esp32-example: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Observable + ref: 3.0.1 + path: deps/ESPressio-Observable + - name: Install PlatformIO + run: pip install platformio + - name: Create PlatformIO consumer project + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/espressio-command-ci/src" "$RUNNER_TEMP/espressio-command-ci/lib" + rsync -a --exclude='.git' --exclude='deps' --exclude='build' ./ "$RUNNER_TEMP/espressio-command-ci/lib/ESPressio-Command/" + cp -R deps/ESPressio-Observable "$RUNNER_TEMP/espressio-command-ci/lib/ESPressio-Observable" + cat > "$RUNNER_TEMP/espressio-command-ci/platformio.ini" <<'EOF' + [env:esp32dev] + platform = espressif32 + board = esp32dev + framework = arduino + build_flags = + -std=gnu++17 + -frtti + build_unflags = + -std=gnu++11 + -fno-rtti + lib_deps = + ESPressio-Command + ESPressio-Observable + EOF + - name: Compile BasicCommand + shell: bash + run: | + cp examples/BasicCommand/BasicCommand.ino "$RUNNER_TEMP/espressio-command-ci/src/main.cpp" + pio run -d "$RUNNER_TEMP/espressio-command-ci"