From fbfe8cf23ec7e8a25d5291c7db0ad29321757d8e Mon Sep 17 00:00:00 2001 From: Chad Attermann Date: Thu, 18 Jun 2026 11:39:23 -0600 Subject: [PATCH] Implemented remote provsisioning/management - Added Link request handler for "/provision" path using same existing "rnstransport/remote/management" aspect. - Added namespace commit callback for handling of sets of fields as a group. --- platformio.ini | 1 + src/microReticulum/Provisioning/Namespace.h | 19 +++ .../Provisioning/NamespaceBuilder.cpp | 7 + .../Provisioning/Provisioning.cpp | 34 ++++- .../Provisioning/Provisioning.h | 8 + src/microReticulum/Transport.cpp | 17 +++ src/microReticulum/Transport.h | 3 + test/test_provisioning/test_provisioning.cpp | 137 ++++++++++++++++++ 8 files changed, 225 insertions(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 4a947ad..26e3edf 100644 --- a/platformio.ini +++ b/platformio.ini @@ -42,6 +42,7 @@ build_flags = -DRNS_USE_FS -DRNS_PERSIST_PATHS -DRNS_USE_PROVISIONING + -DRNS_ENABLE_REMOTE_PROVISIONING -DUSTORE_USE_UNIVERSALFS lib_deps = ArduinoJson@^7.4.2 diff --git a/src/microReticulum/Provisioning/Namespace.h b/src/microReticulum/Provisioning/Namespace.h index 7497e6b..84cb977 100644 --- a/src/microReticulum/Provisioning/Namespace.h +++ b/src/microReticulum/Provisioning/Namespace.h @@ -17,12 +17,24 @@ #include "Field.h" #include +#include #include #include #include namespace RNS { namespace Provisioning { + class Namespace; + + // Fires once per Manager::commit on a namespace that has at least one + // pending draft entry. Invoked BEFORE any field setter fires, so the + // callback can inspect the pending drafts via has_draft()/draft() and + // validate, modify (set_draft), or revert them (clear_draft) before + // the commit proceeds. After the callback returns, the manager + // re-collects the draft id list — so additions and reverts both take + // effect on this same commit pass. + using CommitCallback = std::function; + class Namespace { public: @@ -92,6 +104,12 @@ namespace RNS { namespace Provisioning { // commit_one). No validation, no setter invocation. void put_working(fid_t field_id, const Value& v); + // Optional per-namespace commit hook. See CommitCallback above for + // semantics. Pass nullptr to clear a previously registered callback. + void on_commit(CommitCallback cb) { _on_commit = std::move(cb); } + bool has_on_commit() const { return (bool)_on_commit; } + const CommitCallback& on_commit_callback() const { return _on_commit; } + private: nid_t _id; fstring_t _name; @@ -101,6 +119,7 @@ namespace RNS { namespace Provisioning { std::unordered_map _name_index; std::unordered_map _working; std::unordered_map _draft; + CommitCallback _on_commit; bool _dirty_for_persist = false; }; diff --git a/src/microReticulum/Provisioning/NamespaceBuilder.cpp b/src/microReticulum/Provisioning/NamespaceBuilder.cpp index 2087267..b0705c3 100644 --- a/src/microReticulum/Provisioning/NamespaceBuilder.cpp +++ b/src/microReticulum/Provisioning/NamespaceBuilder.cpp @@ -296,4 +296,11 @@ namespace RNS { namespace Provisioning { return *this; } + NamespaceBuilder& NamespaceBuilder::on_commit(CommitCallback cb) { + Namespace* ns = scope(_mgr, "on_commit"); + if (!ns) return *this; + ns->on_commit(std::move(cb)); + return *this; + } + } } diff --git a/src/microReticulum/Provisioning/Provisioning.cpp b/src/microReticulum/Provisioning/Provisioning.cpp index 49b141d..99ac38b 100644 --- a/src/microReticulum/Provisioning/Provisioning.cpp +++ b/src/microReticulum/Provisioning/Provisioning.cpp @@ -184,7 +184,24 @@ namespace RNS { namespace Provisioning { bool Manager::commit(nid_t ns_id) { auto do_one = [&](Namespace& ns) -> bool { bool any_reboot = false; - // Collect ids first; commit_one() mutates _draft. + // Fire the pre-commit hook before any field setter runs, but + // only when this namespace actually has pending drafts — the + // callback contract is "called when there is something to + // commit". The callback may revert (clear_draft) or amend + // (set_draft) drafts, so we re-collect the id list after it + // returns to honour those edits. + bool has_any_draft = false; + for (const Field& f : ns.fields()) { + if (ns.has_draft(f.id)) { has_any_draft = true; break; } + } + if (has_any_draft && ns.has_on_commit()) { + try { ns.on_commit_callback()(ns); } + catch (const std::exception& e) { + ERRORF("Provisioning::commit: on_commit callback for namespace %u threw: %s", + ns.id(), e.what()); + } + } + // Collect ids after the callback; commit_one() mutates _draft. std::vector ids; for (const Field& f : ns.fields()) { if (ns.has_draft(f.id)) ids.push_back(f.id); @@ -641,6 +658,21 @@ namespace RNS { namespace Provisioning { bool any_reboot = false; auto do_one = [&](Namespace& ns) { + // Pre-commit hook: fires once per namespace iff at least one + // draft entry exists, before any field setter runs. Mirrors + // the in-process Manager::commit path so wire-driven commits + // see identical semantics. + bool has_any_draft = false; + for (const Field& f : ns.fields()) { + if (ns.has_draft(f.id)) { has_any_draft = true; break; } + } + if (has_any_draft && ns.has_on_commit()) { + try { ns.on_commit_callback()(ns); } + catch (const std::exception& e) { + ERRORF("Provisioning::op_commit: on_commit callback for namespace %u threw: %s", + ns.id(), e.what()); + } + } std::vector ids; for (const Field& f : ns.fields()) if (ns.has_draft(f.id)) ids.push_back(f.id); for (fid_t id : ids) { diff --git a/src/microReticulum/Provisioning/Provisioning.h b/src/microReticulum/Provisioning/Provisioning.h index cd24572..4190834 100644 --- a/src/microReticulum/Provisioning/Provisioning.h +++ b/src/microReticulum/Provisioning/Provisioning.h @@ -154,6 +154,14 @@ namespace RNS { namespace Provisioning { NamespaceBuilder& command_void(const char* name, fid_t id, std::function setter); + // Register a pre-commit hook for the current namespace scope. Fires + // once per commit pass on this namespace, only when at least one + // draft entry exists, and BEFORE any field setter runs — so the + // callback can validate the pending drafts and revert (clear_draft) + // or amend (set_draft) them before commit_one promotes them into + // working state. See Namespace::CommitCallback for semantics. + NamespaceBuilder& on_commit(CommitCallback cb); + private: Manager* _mgr; }; diff --git a/src/microReticulum/Transport.cpp b/src/microReticulum/Transport.cpp index d9bf778..3c0dc8c 100644 --- a/src/microReticulum/Transport.cpp +++ b/src/microReticulum/Transport.cpp @@ -25,6 +25,10 @@ #include "Utilities/OS.h" #include "Utilities/Persistence.h" +#if defined(RNS_ENABLE_REMOTE_PROVISIONING) && defined(RNS_USE_PROVISIONING) +#include "Provisioning/Provisioning.h" +#endif + #include #include @@ -267,9 +271,15 @@ DestinationEntry empty_destination_entry; //_remote_management_destination.register_request_handler({"/status"}, remote_status_handler, Type::Destination::ALLOW_ALL); _remote_management_destination.register_request_handler("/path", remote_path_handler, Type::Destination::ALLOW_LIST, _remote_management_allowed); //_remote_management_destination.register_request_handler("/path", remote_path_handler, Type::Destination::ALLOW_ALL); +#if defined(RNS_ENABLE_REMOTE_PROVISIONING) && defined(RNS_USE_PROVISIONING) + _remote_management_destination.register_request_handler("/provision", remote_provision_handler, Type::Destination::ALLOW_LIST, _remote_management_allowed); +#endif _mgmt_destinations.insert(_remote_management_destination); _mgmt_hashes.insert(_remote_management_destination.hash()); NOTICEF("Enabled remote management on <%s>", _remote_management_destination.toString().c_str()); +#if defined(RNS_ENABLE_REMOTE_PROVISIONING) && defined(RNS_USE_PROVISIONING) + NOTICEF("Enabled remote provisioning on <%s>", _remote_management_destination.toString().c_str()); +#endif } /*p @@ -3674,6 +3684,13 @@ static void remote_path_pack_rate_entry(MsgPack::Packer& p, return Bytes(p.data(), p.size()); } +#if defined(RNS_ENABLE_REMOTE_PROVISIONING) && defined(RNS_USE_PROVISIONING) +/*static*/ Bytes Transport::remote_provision_handler(const Bytes& path, const Bytes& data, const Bytes& request_id, const Bytes& link_id, const Identity& remote_identity, double requested_at) { + TRACEF("remote_provision_handler: forwarding %u bytes to Provisioning::Manager", (unsigned)data.size()); + return Provisioning::Manager::instance().handle_message(data); +} +#endif + /*static*/ void Transport::path_request_handler(const Bytes& data, const Packet& packet) { TRACE("Transport::path_request_handler"); try { diff --git a/src/microReticulum/Transport.h b/src/microReticulum/Transport.h index 6b3ca4f..5be8c7b 100644 --- a/src/microReticulum/Transport.h +++ b/src/microReticulum/Transport.h @@ -320,6 +320,9 @@ namespace RNS { static void request_path(const Bytes& destination_hash); static Bytes remote_status_handler(const Bytes& path, const Bytes& data, const Bytes& request_id, const Bytes& link_id, const Identity& remote_identity, double requested_at); static Bytes remote_path_handler(const Bytes& path, const Bytes& data, const Bytes& request_id, const Bytes& link_id, const Identity& remote_identity, double requested_at); +#if defined(RNS_ENABLE_REMOTE_PROVISIONING) && defined(RNS_USE_PROVISIONING) + static Bytes remote_provision_handler(const Bytes& path, const Bytes& data, const Bytes& request_id, const Bytes& link_id, const Identity& remote_identity, double requested_at); +#endif static void path_request_handler(const Bytes& data, const Packet& packet); static void path_request(const Bytes& destination_hash, bool is_from_local_client, const Interface& attached_interface, const Bytes& requestor_transport_id = {}, const Bytes& tag = {}); static bool from_local_client(const Packet& packet); diff --git a/test/test_provisioning/test_provisioning.cpp b/test/test_provisioning/test_provisioning.cpp index 6cecabd..5af39a0 100644 --- a/test/test_provisioning/test_provisioning.cpp +++ b/test/test_provisioning/test_provisioning.cpp @@ -1200,6 +1200,137 @@ void test_duplicate_field_id_rejected(void) { TEST_ASSERT_EQUAL(1u, ns->fields().size()); // duplicate dropped } +// --------------------------------------------------------------------------- +// Namespace-level on_commit callback +// --------------------------------------------------------------------------- + +void test_on_commit_callback_fires_only_when_drafts_present(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + Namespace* ns = p.registry().find(CUSTOM_NS_ID); + TEST_ASSERT_NOT_NULL(ns); + + int callback_count = 0; + ns->on_commit([&](Namespace&) { ++callback_count; }); + + // No drafts staged → commit should skip the callback entirely. + TEST_ASSERT_TRUE(p.commit(CUSTOM_NS_ID)); + TEST_ASSERT_EQUAL(0, callback_count); + + // Stage a draft → callback fires exactly once on this commit pass. + TEST_ASSERT_TRUE(p.field(CUSTOM_NS_ID, CUSTOM_INT, Value((int64_t)42))); + TEST_ASSERT_TRUE(p.commit(CUSTOM_NS_ID)); + TEST_ASSERT_EQUAL(1, callback_count); + + // And the field's own setter still ran. + TEST_ASSERT_EQUAL(1, g_live_int_setter_count); + TEST_ASSERT_EQUAL_INT64(42, g_live_int_setter_value); +} + +void test_on_commit_callback_runs_before_field_setters(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + Namespace* ns = p.registry().find(CUSTOM_NS_ID); + TEST_ASSERT_NOT_NULL(ns); + + int setter_count_observed = -1; + ns->on_commit([&](Namespace&) { + // Snapshot the field setter counter at the moment the namespace + // callback fires — it must still be 0, proving the namespace + // callback wins the ordering race against commit_one's setter. + setter_count_observed = g_live_int_setter_count; + }); + + TEST_ASSERT_TRUE(p.field(CUSTOM_NS_ID, CUSTOM_INT, Value((int64_t)11))); + TEST_ASSERT_TRUE(p.commit(CUSTOM_NS_ID)); + TEST_ASSERT_EQUAL(0, setter_count_observed); + TEST_ASSERT_EQUAL(1, g_live_int_setter_count); +} + +void test_on_commit_callback_can_revert_specific_draft(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + Namespace* ns = p.registry().find(CUSTOM_NS_ID); + TEST_ASSERT_NOT_NULL(ns); + + ns->on_commit([](Namespace& self) { + // Veto the level change but allow the label change to proceed. + self.clear_draft(CUSTOM_INT); + }); + + TEST_ASSERT_TRUE(p.field(CUSTOM_NS_ID, CUSTOM_INT, Value((int64_t)42))); + TEST_ASSERT_TRUE(p.field(CUSTOM_NS_ID, CUSTOM_STR, Value("ok"))); + TEST_ASSERT_TRUE(p.commit(CUSTOM_NS_ID)); + + // Reverted draft: setter never fires, working untouched. + TEST_ASSERT_EQUAL(0, g_live_int_setter_count); + TEST_ASSERT_EQUAL_INT64(5, p.field(CUSTOM_NS_ID, CUSTOM_INT).as_int()); + // Unreverted draft: still applied. + TEST_ASSERT_EQUAL_STRING("ok", p.field(CUSTOM_NS_ID, CUSTOM_STR).as_string().c_str()); +} + +void test_on_commit_callback_can_revert_entire_namespace(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + Namespace* ns = p.registry().find(CUSTOM_NS_ID); + TEST_ASSERT_NOT_NULL(ns); + + ns->on_commit([](Namespace& self) { self.clear_draft(); }); + + TEST_ASSERT_TRUE(p.field(CUSTOM_NS_ID, CUSTOM_INT, Value((int64_t)42))); + TEST_ASSERT_TRUE(p.field(CUSTOM_NS_ID, CUSTOM_STR, Value("vetoed"))); + TEST_ASSERT_TRUE(p.commit(CUSTOM_NS_ID)); + + TEST_ASSERT_EQUAL(0, g_live_int_setter_count); + TEST_ASSERT_EQUAL_INT64(5, p.field(CUSTOM_NS_ID, CUSTOM_INT).as_int()); + TEST_ASSERT_EQUAL_STRING("default", p.field(CUSTOM_NS_ID, CUSTOM_STR).as_string().c_str()); +} + +void test_on_commit_callback_can_amend_draft(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + Namespace* ns = p.registry().find(CUSTOM_NS_ID); + TEST_ASSERT_NOT_NULL(ns); + + ns->on_commit([](Namespace& self) { + // Clamp the level draft to 50 — exercises set_draft inside the hook. + Value v; + if (self.draft(CUSTOM_INT, v) && v.as_int() > 50) { + self.set_draft(CUSTOM_INT, Value((int64_t)50)); + } + }); + + TEST_ASSERT_TRUE(p.field(CUSTOM_NS_ID, CUSTOM_INT, Value((int64_t)90))); + TEST_ASSERT_TRUE(p.commit(CUSTOM_NS_ID)); + + TEST_ASSERT_EQUAL(1, g_live_int_setter_count); + TEST_ASSERT_EQUAL_INT64(50, g_live_int_setter_value); + TEST_ASSERT_EQUAL_INT64(50, p.field(CUSTOM_NS_ID, CUSTOM_INT).as_int()); +} + +void test_on_commit_callback_scoped_per_namespace(void) { + // Two namespaces with their own callbacks. A draft on one must not + // fire the other's hook. + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + Namespace* ns_custom = p.registry().find(CUSTOM_NS_ID); + TEST_ASSERT_NOT_NULL(ns_custom); + Namespace* ns_other = p.registry().find(Ns::Transport::Id); + TEST_ASSERT_NOT_NULL(ns_other); + + int custom_count = 0; + int other_count = 0; + ns_custom->on_commit([&](Namespace&) { ++custom_count; }); + ns_other->on_commit([&](Namespace&) { ++other_count; }); + + TEST_ASSERT_TRUE(p.field(CUSTOM_NS_ID, CUSTOM_INT, Value((int64_t)42))); + // Global commit walks every namespace; only the one with a draft + // should invoke its hook. + TEST_ASSERT_TRUE(p.commit()); + TEST_ASSERT_EQUAL(1, custom_count); + TEST_ASSERT_EQUAL(0, other_count); +} + // --------------------------------------------------------------------------- // Wire round-trip tests — exercise handle_message() // --------------------------------------------------------------------------- @@ -1419,6 +1550,12 @@ int runUnityTests(void) { RUN_TEST(test_factory_reset); RUN_TEST(test_by_name_accessors); RUN_TEST(test_duplicate_field_id_rejected); + RUN_TEST(test_on_commit_callback_fires_only_when_drafts_present); + RUN_TEST(test_on_commit_callback_runs_before_field_setters); + RUN_TEST(test_on_commit_callback_can_revert_specific_draft); + RUN_TEST(test_on_commit_callback_can_revert_entire_namespace); + RUN_TEST(test_on_commit_callback_can_amend_draft); + RUN_TEST(test_on_commit_callback_scoped_per_namespace); RUN_TEST(test_wire_get_info); RUN_TEST(test_wire_set_state_then_commit); RUN_TEST(test_wire_set_state_constraint_error);