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" 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. diff --git a/README.md b/README.md index 261f0c8..6f67732 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,24 @@ 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. +## 0.3.0 Development Update — Observable Callback Coverage + +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. + +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. + +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)**. @@ -23,7 +41,7 @@ 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. +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. Host-side tests are also provided so that the transport-neutral core can be validated with a conventional C++17 toolchain. @@ -84,12 +102,9 @@ In the dependency chart: ### Required ESPressio dependencies -**None.** +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.** -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. +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 @@ -110,16 +125,20 @@ The principal public types are: - `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 -You can add the published library to a PlatformIO project with: +For the stable/pre-release 0.2.0 generation: ```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: @@ -302,6 +321,31 @@ 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 diagnostics / discovery refresh. + } + + void OnCommandUnregistered(const std::vector& path) override { + // Owned registration lifetime ended. + } +}; + +RegistryObserver observer; +auto observerHandle = commands.RegisterObserver(&observer); +``` + +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 @@ -386,7 +430,7 @@ rules: 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.** +7. **The core remains independently useful; from 0.3.0 its only required ESPressio dependency is Observable.** ## Examples @@ -404,7 +448,7 @@ 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. +validate intended ESP32 integration usage. The 0.3.0 generation also validates registry-observer registration lifetime and notification semantics. ## Future Integration Direction @@ -420,7 +464,7 @@ ESPressio Command is intended to become the common invocation layer for: - cancellation/progress for asynchronous operations; - remote Command invocation; - JSON/Serializable argument adapters; and -- Event bridges for Command completion/result Events. +- 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: @@ -428,7 +472,7 @@ important: ```text Serial adapter --------+ Network adapter -------+ -Serializable adapter --+--> ESPressio Command +Serializable adapter --+--> ESPressio Command --> ESPressio Observable Event bridge ----------+ ``` 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 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" + } + ] } 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) 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 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 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 74875bd..33bd48f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -5,7 +5,24 @@ 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 3.0.1 + GIT_SHALLOW TRUE +) +FetchContent_GetProperties(ESPressioObservable) +if(NOT espressioobservable_POPULATED) + FetchContent_Populate(ESPressioObservable) +endif() +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) 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; +}