Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/microReticulum/Provisioning/Namespace.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,24 @@
#include "Field.h"

#include <stdint.h>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>

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<void(Namespace&)>;

class Namespace {

public:
Expand Down Expand Up @@ -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;
Expand All @@ -101,6 +119,7 @@ namespace RNS { namespace Provisioning {
std::unordered_map<fstring_t, size_t> _name_index;
std::unordered_map<fid_t, Value> _working;
std::unordered_map<fid_t, Value> _draft;
CommitCallback _on_commit;
bool _dirty_for_persist = false;
};

Expand Down
7 changes: 7 additions & 0 deletions src/microReticulum/Provisioning/NamespaceBuilder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

} }
34 changes: 33 additions & 1 deletion src/microReticulum/Provisioning/Provisioning.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<fid_t> ids;
for (const Field& f : ns.fields()) {
if (ns.has_draft(f.id)) ids.push_back(f.id);
Expand Down Expand Up @@ -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<fid_t> ids;
for (const Field& f : ns.fields()) if (ns.has_draft(f.id)) ids.push_back(f.id);
for (fid_t id : ids) {
Expand Down
8 changes: 8 additions & 0 deletions src/microReticulum/Provisioning/Provisioning.h
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,14 @@ namespace RNS { namespace Provisioning {
NamespaceBuilder& command_void(const char* name, fid_t id,
std::function<bool()> 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;
};
Expand Down
17 changes: 17 additions & 0 deletions src/microReticulum/Transport.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <MsgPack.h>

#include <algorithm>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions src/microReticulum/Transport.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
137 changes: 137 additions & 0 deletions test/test_provisioning/test_provisioning.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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);
Expand Down
Loading