diff --git a/CMakeLists.txt b/CMakeLists.txt index ccd62dd..60d350e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,8 +20,9 @@ endif() option(RNS_BUILD_TESTS "Build Unity test suites under test/" ON) option(RNS_BUILD_EXAMPLES "Build native example applications" ON) option(RNS_BUILD_INTEROP "Build interop senders under test_interop/" ON) -option(RNS_USE_FS "Enable filesystem persistence" ON) -option(RNS_PERSIST_PATHS "Persist path table to storage" ON) +option(RNS_USE_FS "Enable filesystem persistence" ON) +option(RNS_PERSIST_PATHS "Persist path table to storage" ON) +option(RNS_USE_PROVISIONING "Auto-start Provisioning subsystem from Reticulum::start()" ON) option(RNS_DEBUG_MEMORY "Enable memory/heap/metrics debug logging" OFF) option(RNS_SANITIZE "Build with AddressSanitizer + frame pointers" OFF) @@ -125,6 +126,9 @@ endif() if(RNS_PERSIST_PATHS) target_compile_definitions(microReticulum PUBLIC RNS_PERSIST_PATHS) endif() +if(RNS_USE_PROVISIONING) + target_compile_definitions(microReticulum PUBLIC RNS_USE_PROVISIONING) +endif() if(RNS_DEBUG_MEMORY) target_compile_definitions(microReticulum PUBLIC RNS_DEBUG_HEAP RNS_DEBUG_MEMORY RNS_DEBUG_METRICS RNS_DEBUG_PATHSTORE) diff --git a/platformio.ini b/platformio.ini index 5ce4bd4..32a014d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -9,12 +9,20 @@ ; https://docs.platformio.org/page/projectconf.html [platformio] -default_envs = native14 +default_envs = + native17 + ttgo-lora32-v21 + ttgo-t-beam + lilygo_tbeam_supreme + heltec_wifi_lora_32_V4 + wiscore_rak4631 [env] build_unflags = + -std=gnu++11 -fno-exceptions build_flags = + -std=gnu++17 -fexceptions -Wall ;-Wextra @@ -29,6 +37,7 @@ build_flags = -DRNS_DEBUG_PATHSTORE -DRNS_USE_FS -DRNS_PERSIST_PATHS + -DRNS_USE_PROVISIONING -DUSTORE_USE_UNIVERSALFS lib_deps = ArduinoJson@^7.4.2 @@ -42,7 +51,6 @@ test_build_src = true platform = native build_flags = ${env.build_flags} - -std=gnu++11 -DNATIVE lib_deps = ${env.lib_deps} @@ -54,7 +62,6 @@ lib_compat_mode = off platform = native build_flags = ${env.build_flags} - -std=gnu++11 -DNATIVE lib_deps = ${env.lib_deps} @@ -62,11 +69,26 @@ lib_deps = ; Following required to force-include libraries that are marked arduino-only lib_compat_mode = off -[env:native14] +[env:native11] platform = native build_unflags = ${env.build_unflags} + -std=gnu++17 +build_flags = + ${env.build_flags} -std=gnu++11 + -DNATIVE +lib_deps = + ${env.lib_deps} + https://github.com/attermann/microStore.git +; Following required to force-include libraries that are marked arduino-only +lib_compat_mode = off + +[env:native14] +platform = native +build_unflags = + ${env.build_unflags} + -std=gnu++17 build_flags = ${env.build_flags} -std=gnu++14 @@ -81,10 +103,8 @@ lib_compat_mode = off platform = native build_unflags = ${env.build_unflags} - -std=gnu++11 build_flags = ${env.build_flags} - -std=gnu++17 -DNATIVE lib_deps = ${env.lib_deps} @@ -96,7 +116,7 @@ lib_compat_mode = off platform = native build_unflags = ${env.build_unflags} - -std=gnu++11 + -std=gnu++17 build_flags = ${env.build_flags} -std=gnu++20 @@ -111,11 +131,8 @@ lib_compat_mode = off platform = native build_unflags = ${env.build_unflags} - -std=gnu++11 build_flags = ${env.build_flags} - ;-std=gnu++11 - -std=gnu++17 -DNATIVE ; CBA TEST ;-fsanitize=address @@ -150,11 +167,9 @@ monitor_speed = 115200 upload_speed = 460800 build_unflags = ${env.build_unflags} - -std=gnu++11 ;-fno-rtti build_flags = ${env.build_flags} - -std=gnu++17 lib_deps = ${env.lib_deps} https://github.com/attermann/microStore.git @@ -166,10 +181,8 @@ board = ttgo-lora32-v21 board_build.partitions = no_ota.csv build_unflags = ${env:embedded.build_unflags} - -std=gnu++11 build_flags = ${env:embedded.build_flags} - -std=gnu++17 -DBOARD_ESP32 -DMSGPACK_USE_BOOST=OFF lib_deps = @@ -264,7 +277,6 @@ lib_deps = platform = native build_flags = ${env.build_flags} - -std=gnu++11 -DNATIVE lib_deps = ${env.lib_deps} diff --git a/src/Provisioning/BuiltinNamespaces.cpp b/src/Provisioning/BuiltinNamespaces.cpp new file mode 100644 index 0000000..882879b --- /dev/null +++ b/src/Provisioning/BuiltinNamespaces.cpp @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#include "Provisioning.h" +#include "Ids.h" + +#include "../Reticulum.h" +#include "../Transport.h" + +namespace RNS { namespace Provisioning { + + // Idempotent: re-calling does nothing because Registry rejects duplicate + // namespace ids. Called automatically from Manager::begin(). + void register_builtin_namespaces(Manager& p) { + + // reticulum + if (!p.registry().find(Ns::Reticulum::Id)) { + p.register_namespace("reticulum", Ns::Reticulum::Id) + .field_bool("transport_enabled", Ns::Reticulum::Field::TransportEnabled, + FF_LIVE_APPLY, Reticulum::transport_enabled(), + [](const Value& v) { Reticulum::transport_enabled(v.as_bool()); return true; }) + .field_bool("link_mtu_discovery", Ns::Reticulum::Field::LinkMtuDiscovery, + FF_REBOOT_REQUIRED, Reticulum::link_mtu_discovery(), + [](const Value& v) { Reticulum::link_mtu_discovery(v.as_bool()); return true; }) + .field_bool("remote_management_enabled", Ns::Reticulum::Field::RemoteManagementEnabled, + FF_LIVE_APPLY, Reticulum::remote_management_enabled(), + [](const Value& v) { Reticulum::remote_management_enabled(v.as_bool()); return true; }) + .field_bool("probe_destination_enabled", Ns::Reticulum::Field::ProbeDestinationEnabled, + FF_LIVE_APPLY, Reticulum::probe_destination_enabled(), + [](const Value& v) { Reticulum::probe_destination_enabled(v.as_bool()); return true; }) + .field_int("persist_interval", Ns::Reticulum::Field::PersistInterval, + FF_LIVE_APPLY, (int64_t)Reticulum::persist_interval(), 30, 86400, + [](const Value& v) { Reticulum::persist_interval((uint16_t)v.as_int()); return true; }) + .field_int("clean_interval", Ns::Reticulum::Field::CleanInterval, + FF_LIVE_APPLY, (int64_t)Reticulum::clean_interval(), 60, 86400, + [](const Value& v) { Reticulum::clean_interval((uint16_t)v.as_int()); return true; }) + .end(); + } + + // transport + if (!p.registry().find(Ns::Transport::Id)) { + p.register_namespace("transport", Ns::Transport::Id) + .field_int("path_table_maxsize", Ns::Transport::Field::PathTableMaxsize, + FF_LIVE_APPLY, (int64_t)RNS::Transport::path_table_maxsize(), 1, 65535, + [](const Value& v) { RNS::Transport::path_table_maxsize((uint16_t)v.as_int()); return true; }) + .field_int("announce_table_maxsize", Ns::Transport::Field::AnnounceTableMaxsize, + FF_LIVE_APPLY, (int64_t)RNS::Transport::announce_table_maxsize(), 1, 65535, + [](const Value& v) { RNS::Transport::announce_table_maxsize((uint16_t)v.as_int()); return true; }) + .field_int("hashlist_maxsize", Ns::Transport::Field::HashlistMaxsize, + FF_LIVE_APPLY, (int64_t)RNS::Transport::hashlist_maxsize(), 1, 65535, + [](const Value& v) { RNS::Transport::hashlist_maxsize((uint16_t)v.as_int()); return true; }) + .field_int("max_pr_tags", Ns::Transport::Field::MaxPrTags, + FF_LIVE_APPLY, (int64_t)RNS::Transport::max_pr_tags(), 1, 65535, + [](const Value& v) { RNS::Transport::max_pr_tags((uint16_t)v.as_int()); return true; }) + .field_int("path_table_maxpersist", Ns::Transport::Field::PathTableMaxpersist, + FF_LIVE_APPLY, (int64_t)RNS::Transport::path_table_maxpersist(), 1, 65535, + [](const Value& v) { RNS::Transport::path_table_maxpersist((uint16_t)v.as_int()); return true; }) + .end(); + } + + // Note: an "identity" built-in namespace (id 3) was prototyped earlier + // but dropped because the library has no canonical identity to expose. + // Apps that want to surface an identity-like field should register + // their own namespace using an id in the 100-199 (official-app) or + // 200+ (vendor) range. Id 3 is permanently reserved. + + } + +} } diff --git a/src/Provisioning/Codec.cpp b/src/Provisioning/Codec.cpp new file mode 100644 index 0000000..8809865 --- /dev/null +++ b/src/Provisioning/Codec.cpp @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#include "Codec.h" + +#include "../Log.h" + +namespace RNS { namespace Provisioning { namespace Codec { + + bool pack_value(MsgPack::Packer& packer, const Value& v) { + switch (v.type()) { + case Type::None: + return false; + case Type::Bool: + packer.serialize((bool)v.as_bool()); + return true; + case Type::Int: + case Type::Enum: + packer.serialize((int64_t)v.as_int()); + return true; + case Type::Float: + packer.serialize((double)v.as_float()); + return true; + case Type::String: + packer.serialize(v.as_string().c_str()); + return true; + case Type::Bytes: { + const Bytes& b = v.as_bytes(); + MsgPack::bin_t bin; + if (b.size() > 0) { + bin.resize(b.size()); + memcpy(bin.data(), b.data(), b.size()); + } + packer.serialize(bin); + return true; + } + } + return false; + } + + bool unpack_value(MsgPack::Unpacker& unpacker, Type declared, Value& out) { + switch (declared) { + case Type::None: + return false; + case Type::Bool: { + if (!unpacker.isBool()) return false; + bool b = false; + unpacker.deserialize(b); + out = Value(b); + return true; + } + case Type::Int: + case Type::Enum: { + if (!unpacker.isInt() && !unpacker.isUInt()) return false; + int64_t iv = 0; + unpacker.deserialize(iv); + out = Value::from_int_as(declared, iv); + return true; + } + case Type::Float: { + // Floats may arrive as int on wire; accept either. + if (unpacker.isFloat32() || unpacker.isFloat64() + || unpacker.isInt() || unpacker.isUInt()) { + double d = 0.0; + unpacker.deserialize(d); + out = Value(d); + return true; + } + return false; + } + case Type::String: { + if (!unpacker.isStr()) return false; + MsgPack::str_t s; + unpacker.deserialize(s); + out = Value(to_std_string(s)); + return true; + } + case Type::Bytes: { + if (!unpacker.isBin()) return false; + MsgPack::bin_t bin; + unpacker.deserialize(bin); + Bytes b(bin.data(), bin.size()); + out = Value(b); + return true; + } + } + return false; + } + + bool pack_namespace_working(MsgPack::Packer& packer, const Namespace& ns) { + // Count first so we can emit the correct map header size. + size_t n = 0; + for (const Field& f : ns.fields()) { + if (f.has_flag(FF_READ_ONLY)) continue; + Value v = ns.working(f.id); + if (v.is_none()) continue; + ++n; + } + packer.serialize(MsgPack::map_size_t(n)); + for (const Field& f : ns.fields()) { + if (f.has_flag(FF_READ_ONLY)) continue; + Value v = ns.working(f.id); + if (v.is_none()) continue; + packer.serialize((uint16_t)f.id); + pack_value(packer, v); + } + return true; + } + + // Skip whatever value the cursor points at. Hideakitai MsgPack doesn't + // expose a generic skip(); we deserialize into a throwaway typed local. + static void skip_value(MsgPack::Unpacker& u) { + if (u.isNil()) { MsgPack::object::nil_t n; u.deserialize(n); } + else if (u.isBool()) { bool b; u.deserialize(b); } + else if (u.isUInt() || u.isInt()) { int64_t i; u.deserialize(i); } + else if (u.isFloat32() || u.isFloat64()) { double d; u.deserialize(d); } + else if (u.isStr()) { MsgPack::str_t s; u.deserialize(s); } + else if (u.isBin()) { MsgPack::bin_t b; u.deserialize(b); } + else if (u.isArray()) { + const size_t n = u.unpackArraySize(); + for (size_t i = 0; i < n; ++i) skip_value(u); + } + else if (u.isMap()) { + const size_t n = u.unpackMapSize(); + for (size_t i = 0; i < n; ++i) { skip_value(u); skip_value(u); } + } + else { + WARNING("Codec::skip_value: unknown MsgPack type encountered"); + } + } + + bool unpack_namespace_working(MsgPack::Unpacker& unpacker, Namespace& ns) { + if (!unpacker.isMap()) return false; + const size_t n = unpacker.unpackMapSize(); + for (size_t i = 0; i < n; ++i) { + if (!(unpacker.isUInt() || unpacker.isInt())) { + // Map key must be an integer field id; skip both halves and + // keep going (forward-compat with future key types). + skip_value(unpacker); + skip_value(unpacker); + continue; + } + int64_t key = 0; + unpacker.deserialize(key); + uint16_t field_id = (uint16_t)key; + const Field* f = ns.find_field(field_id); + if (!f) { + skip_value(unpacker); + continue; + } + Value v; + if (!unpack_value(unpacker, f->type, v)) { + // Type mismatch — skip but don't fail the whole file. + continue; + } + if (!f->validate(v)) { + // Out-of-range or otherwise invalid — leave default in place. + continue; + } + ns.put_working(field_id, v); + } + return true; + } + +} } } diff --git a/src/Provisioning/Codec.h b/src/Provisioning/Codec.h new file mode 100644 index 0000000..f9d29b0 --- /dev/null +++ b/src/Provisioning/Codec.h @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#pragma once + +#include "Value.h" +#include "Field.h" +#include "Namespace.h" + +#define MSGPACK_DEBUGLOG_ENABLE 0 +#include + +namespace RNS { namespace Provisioning { + + // Low-level MsgPack helpers shared by Storage (on-disk) and Provisioning + // (wire). Keep all type-dispatch logic here so the two callers don't + // drift apart. + namespace Codec { + + // Portable str_t -> std::string conversion. On native MsgPack::str_t + // IS std::string; on Arduino targets it's Arduino::String. Both have + // c_str()/length(), so this works without #ifdef. + inline std::string to_std_string(const MsgPack::str_t& s) { + return std::string(s.c_str(), s.length()); + } + + // Pack a Value as MsgPack at the cursor. The Value's declared type + // determines the wire encoding; bool/int/enum all collapse to int. + // Returns false if the value carries Type::None. + bool pack_value(MsgPack::Packer& packer, const Value& v); + + // Read the next MsgPack element into a Value coerced to `declared`. + // Mismatched types (e.g. wire says str but field declares int) fail. + // Unknown / nil values produce Type::None. + bool unpack_value(MsgPack::Unpacker& unpacker, Type declared, Value& out); + + // Serialize the persistable subset of a namespace's working map + // (excludes FF_READ_ONLY fields, which are never written to flash). + // Output is a single MsgPack map: { field_id: value, ... }. + bool pack_namespace_working(MsgPack::Packer& packer, const Namespace& ns); + + // Inverse of pack_namespace_working. Validates each value against + // the field's constraint; invalid values are skipped silently + // (forward-compat with renamed fields whose types changed). + // Unknown field ids are also skipped silently. + bool unpack_namespace_working(MsgPack::Unpacker& unpacker, Namespace& ns); + + } + +} } diff --git a/src/Provisioning/Field.cpp b/src/Provisioning/Field.cpp new file mode 100644 index 0000000..7350401 --- /dev/null +++ b/src/Provisioning/Field.cpp @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#include "Field.h" + +#include + +namespace RNS { namespace Provisioning { + + bool Field::validate(const Value& v) const { + if (v.type() != type) return false; + switch (type) { + case Type::None: + return false; + case Type::Bool: + return true; + case Type::Int: { + if (constraint.has_range) { + int64_t iv = v.as_int(); + if (iv < constraint.imin || iv > constraint.imax) return false; + } + return true; + } + case Type::Float: { + if (constraint.has_range) { + double fv = v.as_float(); + if (fv < constraint.fmin || fv > constraint.fmax) return false; + } + return true; + } + case Type::String: + if (constraint.max_len > 0 && v.as_string().size() > constraint.max_len) return false; + return true; + case Type::Bytes: + if (constraint.max_len > 0 && v.as_bytes().size() > constraint.max_len) return false; + return true; + case Type::Enum: { + int64_t iv = v.as_int(); + if (!constraint.enum_values.empty()) { + return std::find(constraint.enum_values.begin(), constraint.enum_values.end(), iv) + != constraint.enum_values.end(); + } + return true; + } + } + return false; + } + +} } diff --git a/src/Provisioning/Field.h b/src/Provisioning/Field.h new file mode 100644 index 0000000..d702576 --- /dev/null +++ b/src/Provisioning/Field.h @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#pragma once + +#include "Value.h" + +#include +#include +#include +#include + +namespace RNS { namespace Provisioning { + + enum FieldFlags : uint8_t { + FF_NONE = 0, + FF_LIVE_APPLY = 1 << 0, // setter called immediately on commit + FF_REBOOT_REQUIRED = 1 << 1, // committed value takes effect after reboot + FF_READ_ONLY = 1 << 2, // no setter; not modifiable from wire + FF_SECRET = 1 << 3, // excluded from GET_STATE responses + }; + + struct Constraint { + bool has_range = false; + int64_t imin = 0; + int64_t imax = 0; + double fmin = 0.0; + double fmax = 0.0; + size_t max_len = 0; + std::vector enum_values; + std::vector enum_labels; + }; + + using SetterFn = std::function; + + struct Field { + uint16_t id = 0; + std::string name; + Type type = Type::None; + uint8_t flags = FF_NONE; + Constraint constraint; + Value default_value; + SetterFn setter; // optional; invoked on commit for FF_LIVE_APPLY + + bool has_flag(FieldFlags f) const { return (flags & f) != 0; } + + // Returns true iff v is type-compatible with this field and satisfies + // any declared constraint. Does NOT apply the value anywhere. + bool validate(const Value& v) const; + }; + +} } diff --git a/src/Provisioning/Ids.h b/src/Provisioning/Ids.h new file mode 100644 index 0000000..7fd6007 --- /dev/null +++ b/src/Provisioning/Ids.h @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#pragma once + +#include + +// IMPORTANT: ids in this file are *permanent*. See docs/provisioning_id_registry.md. +// Append new fields only. To retire a field, leave its slot reserved. + +namespace RNS { namespace Provisioning { namespace Ns { + + // Reticulum namespace — core stack-level toggles. + namespace Reticulum { + constexpr uint16_t Id = 1; + namespace Field { + constexpr uint16_t SchemaVersion = 0; // reserved across all namespaces + constexpr uint16_t TransportEnabled = 1; + constexpr uint16_t LinkMtuDiscovery = 2; + constexpr uint16_t RemoteManagementEnabled = 3; + constexpr uint16_t ProbeDestinationEnabled = 4; + constexpr uint16_t UseImplicitProof = 5; + constexpr uint16_t PersistInterval = 6; + constexpr uint16_t CleanInterval = 7; + // next-id: 8 + } + } + + // Transport namespace — routing-table sizing tunables. + namespace Transport { + constexpr uint16_t Id = 2; + namespace Field { + constexpr uint16_t SchemaVersion = 0; // reserved + constexpr uint16_t PathTableMaxsize = 1; + constexpr uint16_t AnnounceTableMaxsize = 2; + constexpr uint16_t HashlistMaxsize = 3; + constexpr uint16_t MaxPrTags = 4; + constexpr uint16_t PathTableMaxpersist = 5; + // next-id: 6 + } + } + + // Namespace id 3 was used for an "identity" prototype with a single + // read-only IdentityHash field. Dropped because the library has no + // canonical identity to expose. Per the id-stability rules above, + // id 3 is permanently reserved and must not be re-used. Apps that + // want their own identity-style namespace should pick an id in the + // 100-199 (official-app) or 200+ (vendor) range. + +} } } diff --git a/src/Provisioning/Macros.h b/src/Provisioning/Macros.h new file mode 100644 index 0000000..2a25706 --- /dev/null +++ b/src/Provisioning/Macros.h @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#pragma once + +#include "Provisioning.h" + +// Convenience wrappers for declaring a namespace + its fields in a single +// terse block. These produce nothing at file scope — they're meant to be +// used inside a function body (typically the project-wide built-in +// registration function, or an app's own registration entry point). +// +// void register_my_app_namespaces() { +// RNS_PROVISION_BEGIN(p, "radio", 100) +// RNS_FIELD_FLOAT(p, "frequency", 1, FF_REBOOT_REQUIRED, 915.0e6, 100e6, 1e9, my_setter) +// RNS_FIELD_INT (p, "sf", 4, FF_REBOOT_REQUIRED, 8, 7, 12, my_setter) +// RNS_PROVISION_END(p); +// } +// +// Apps are equally free to call the fluent NamespaceBuilder API directly; +// these macros only exist for visual symmetry with the per-namespace id +// headers under src/Provisioning/Ids/. + +#define RNS_PROVISION_BEGIN(builder_name, ns_name, ns_id) \ + { auto builder_name = RNS::Provisioning::Manager::instance() \ + .register_namespace((ns_name), (ns_id)); + +#define RNS_PROVISION_END(builder_name) \ + builder_name.end(); } + +#define RNS_FIELD_BOOL(builder, name, id, flags, default_value, ...) \ + (builder).field_bool((name), (id), (flags), (default_value), ##__VA_ARGS__); + +#define RNS_FIELD_INT(builder, name, id, flags, default_value, min_val, max_val, ...) \ + (builder).field_int((name), (id), (flags), (default_value), (min_val), (max_val), ##__VA_ARGS__); + +#define RNS_FIELD_FLOAT(builder, name, id, flags, default_value, min_val, max_val, ...) \ + (builder).field_float((name), (id), (flags), (default_value), (min_val), (max_val), ##__VA_ARGS__); + +#define RNS_FIELD_STRING(builder, name, id, flags, default_value, max_len, ...) \ + (builder).field_string((name), (id), (flags), (default_value), (max_len), ##__VA_ARGS__); + +#define RNS_FIELD_BYTES(builder, name, id, flags, default_value, max_len, ...) \ + (builder).field_bytes((name), (id), (flags), (default_value), (max_len), ##__VA_ARGS__); diff --git a/src/Provisioning/Namespace.cpp b/src/Provisioning/Namespace.cpp new file mode 100644 index 0000000..71e718a --- /dev/null +++ b/src/Provisioning/Namespace.cpp @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#include "Namespace.h" + +namespace RNS { namespace Provisioning { + + bool Namespace::add_field(Field f) { + if (_id_index.count(f.id) != 0) return false; + if (!f.name.empty() && _name_index.count(f.name) != 0) return false; + size_t idx = _fields.size(); + _id_index[f.id] = idx; + if (!f.name.empty()) _name_index[f.name] = idx; + // Seed working with the field's declared default. + _working[f.id] = f.default_value; + _fields.push_back(std::move(f)); + return true; + } + + const Field* Namespace::find_field(uint16_t id) const { + auto it = _id_index.find(id); + if (it == _id_index.end()) return nullptr; + return &_fields[it->second]; + } + + const Field* Namespace::find_field(const char* name) const { + if (!name) return nullptr; + auto it = _name_index.find(name); + if (it == _name_index.end()) return nullptr; + return &_fields[it->second]; + } + + Value Namespace::working(uint16_t field_id) const { + auto it = _working.find(field_id); + if (it == _working.end()) return {}; + return it->second; + } + + bool Namespace::draft(uint16_t field_id, Value& out) const { + auto it = _draft.find(field_id); + if (it == _draft.end()) return false; + out = it->second; + return true; + } + + bool Namespace::has_draft(uint16_t field_id) const { + return _draft.count(field_id) != 0; + } + + bool Namespace::set_draft(uint16_t field_id, const Value& v) { + const Field* f = find_field(field_id); + if (!f) return false; + if (f->has_flag(FF_READ_ONLY)) return false; + if (!f->validate(v)) return false; + // If the new draft value equals the current working value, drop the + // draft entry — keeps draft_has_reboot() honest about whether anything + // is actually pending. + Value cur = working(field_id); + if (cur == v) { + _draft.erase(field_id); + return true; + } + _draft[field_id] = v; + return true; + } + + void Namespace::clear_draft(uint16_t field_id) { + _draft.erase(field_id); + } + + void Namespace::clear_draft() { + _draft.clear(); + } + + bool Namespace::draft_has_reboot() const { + for (const auto& kv : _draft) { + const Field* f = find_field(kv.first); + if (f && f->has_flag(FF_REBOOT_REQUIRED)) return true; + } + return false; + } + + void Namespace::put_working(uint16_t field_id, const Value& v) { + _working[field_id] = v; + _dirty_for_persist = true; + } + + Namespace::CommitOutcome Namespace::commit_one(uint16_t field_id) { + const Field* f = find_field(field_id); + if (!f) return CommitOutcome::Unknown; + auto it = _draft.find(field_id); + if (it == _draft.end()) return CommitOutcome::NoChange; + Value v = it->second; + if (f->has_flag(FF_READ_ONLY)) { + _draft.erase(it); + return CommitOutcome::ReadOnly; + } + if (!f->validate(v)) { + return CommitOutcome::InvalidValue; + } + // Promote into working and persist-dirty regardless of live/reboot + // flag — the file on disk is the source of truth for next boot. + _working[field_id] = v; + _dirty_for_persist = true; + _draft.erase(it); + + if (f->has_flag(FF_LIVE_APPLY) && f->setter) { + if (!f->setter(v)) return CommitOutcome::SetterFailed; + return CommitOutcome::AppliedLive; + } + if (f->has_flag(FF_REBOOT_REQUIRED)) { + return CommitOutcome::AppliedReboot; + } + // Field has neither LIVE nor REBOOT — value is stored but no runtime + // effect declared. Treat as applied-live (no-op). + return CommitOutcome::AppliedLive; + } + +} } diff --git a/src/Provisioning/Namespace.h b/src/Provisioning/Namespace.h new file mode 100644 index 0000000..8938d4b --- /dev/null +++ b/src/Provisioning/Namespace.h @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#pragma once + +#include "Field.h" + +#include +#include +#include +#include + +namespace RNS { namespace Provisioning { + + class Namespace { + + public: + Namespace(uint16_t id, const char* name) : _id(id), _name(name ? name : "") {} + + uint16_t id() const { return _id; } + const std::string& name() const { return _name; } + + // Append a field. Returns false if id or name is already taken. + bool add_field(Field f); + + const Field* find_field(uint16_t id) const; + const Field* find_field(const char* name) const; + + const std::vector& fields() const { return _fields; } + + // Effective working value for a field (returns default if not set). + // Returns a none-typed Value if the field id is unknown. + Value working(uint16_t field_id) const; + + // Returns true and writes into 'out' if a draft entry exists for this field. + bool draft(uint16_t field_id, Value& out) const; + bool has_draft(uint16_t field_id) const; + + // Write to draft after validating against the field's constraint. + // Returns false if the field is unknown, read-only, or invalid. + bool set_draft(uint16_t field_id, const Value& v); + + // Drop a single draft entry, or all of them. + void clear_draft(uint16_t field_id); + void clear_draft(); + + // True iff the current draft touches any FF_REBOOT_REQUIRED field. + bool draft_has_reboot() const; + + // Commit a single draft entry into working state and invoke its + // setter when FF_LIVE_APPLY. Removes the draft entry afterward. + enum class CommitOutcome { + NoChange, // no draft for this field + AppliedLive, // promoted and live-applied + AppliedReboot, // promoted; reboot required for effect + InvalidValue, // draft value failed constraint + Unknown, // field id not in this namespace + ReadOnly, // field is read-only + SetterFailed, // FF_LIVE_APPLY setter returned false + }; + CommitOutcome commit_one(uint16_t field_id); + + // True iff any working value differs from the initial default + // (used by storage to decide whether to write a file). + bool dirty_for_persist() const { return _dirty_for_persist; } + void mark_clean() { _dirty_for_persist = false; } + + // Directly set working value (used by Storage on load and by + // commit_one). No validation, no setter invocation. + void put_working(uint16_t field_id, const Value& v); + + private: + uint16_t _id; + std::string _name; + std::vector _fields; + std::unordered_map _id_index; + std::unordered_map _name_index; + std::unordered_map _working; + std::unordered_map _draft; + bool _dirty_for_persist = false; + }; + +} } diff --git a/src/Provisioning/NamespaceBuilder.cpp b/src/Provisioning/NamespaceBuilder.cpp new file mode 100644 index 0000000..c197c1a --- /dev/null +++ b/src/Provisioning/NamespaceBuilder.cpp @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#include "Provisioning.h" + +#include "../Log.h" + +namespace RNS { namespace Provisioning { + + static void warn_if_dup(Namespace* ns, bool ok, uint16_t id, const char* name) { + if (!ok) { + WARNINGF("NamespaceBuilder: duplicate field id=%u name=\"%s\" in namespace %s", + id, name ? name : "", ns ? ns->name().c_str() : "?"); + } + } + + NamespaceBuilder& NamespaceBuilder::field_bool(const char* name, uint16_t id, uint8_t flags, + bool default_value, SetterFn setter) { + if (!_ns) return *this; + Field f; + f.id = id; + f.name = name ? name : ""; + f.type = Type::Bool; + f.flags = flags; + f.default_value = Value(default_value); + f.setter = std::move(setter); + warn_if_dup(_ns, _ns->add_field(std::move(f)), id, name); + return *this; + } + + NamespaceBuilder& NamespaceBuilder::field_int(const char* name, uint16_t id, uint8_t flags, + int64_t default_value, int64_t imin, int64_t imax, SetterFn setter) { + if (!_ns) return *this; + Field f; + f.id = id; + f.name = name ? name : ""; + f.type = Type::Int; + f.flags = flags; + f.constraint.has_range = true; + f.constraint.imin = imin; + f.constraint.imax = imax; + f.default_value = Value((int64_t)default_value); + f.setter = std::move(setter); + warn_if_dup(_ns, _ns->add_field(std::move(f)), id, name); + return *this; + } + + NamespaceBuilder& NamespaceBuilder::field_int(const char* name, uint16_t id, uint8_t flags, + int64_t default_value, SetterFn setter) { + if (!_ns) return *this; + Field f; + f.id = id; + f.name = name ? name : ""; + f.type = Type::Int; + f.flags = flags; + f.default_value = Value((int64_t)default_value); + f.setter = std::move(setter); + warn_if_dup(_ns, _ns->add_field(std::move(f)), id, name); + return *this; + } + + NamespaceBuilder& NamespaceBuilder::field_float(const char* name, uint16_t id, uint8_t flags, + double default_value, double fmin, double fmax, SetterFn setter) { + if (!_ns) return *this; + Field f; + f.id = id; + f.name = name ? name : ""; + f.type = Type::Float; + f.flags = flags; + f.constraint.has_range = true; + f.constraint.fmin = fmin; + f.constraint.fmax = fmax; + f.default_value = Value(default_value); + f.setter = std::move(setter); + warn_if_dup(_ns, _ns->add_field(std::move(f)), id, name); + return *this; + } + + NamespaceBuilder& NamespaceBuilder::field_float(const char* name, uint16_t id, uint8_t flags, + double default_value, SetterFn setter) { + if (!_ns) return *this; + Field f; + f.id = id; + f.name = name ? name : ""; + f.type = Type::Float; + f.flags = flags; + f.default_value = Value(default_value); + f.setter = std::move(setter); + warn_if_dup(_ns, _ns->add_field(std::move(f)), id, name); + return *this; + } + + NamespaceBuilder& NamespaceBuilder::field_string(const char* name, uint16_t id, uint8_t flags, + const char* default_value, size_t max_len, SetterFn setter) { + if (!_ns) return *this; + Field f; + f.id = id; + f.name = name ? name : ""; + f.type = Type::String; + f.flags = flags; + f.constraint.max_len = max_len; + f.default_value = Value(default_value ? default_value : ""); + f.setter = std::move(setter); + warn_if_dup(_ns, _ns->add_field(std::move(f)), id, name); + return *this; + } + + NamespaceBuilder& NamespaceBuilder::field_bytes(const char* name, uint16_t id, uint8_t flags, + const Bytes& default_value, size_t max_len, SetterFn setter) { + if (!_ns) return *this; + Field f; + f.id = id; + f.name = name ? name : ""; + f.type = Type::Bytes; + f.flags = flags; + f.constraint.max_len = max_len; + f.default_value = Value(default_value); + f.setter = std::move(setter); + warn_if_dup(_ns, _ns->add_field(std::move(f)), id, name); + return *this; + } + + NamespaceBuilder& NamespaceBuilder::field_enum(const char* name, uint16_t id, uint8_t flags, + int64_t default_value, + std::vector values, + std::vector labels, + SetterFn setter) { + if (!_ns) return *this; + Field f; + f.id = id; + f.name = name ? name : ""; + f.type = Type::Enum; + f.flags = flags; + f.constraint.enum_values = std::move(values); + f.constraint.enum_labels = std::move(labels); + f.default_value = Value::from_int_as(Type::Enum, default_value); + f.setter = std::move(setter); + warn_if_dup(_ns, _ns->add_field(std::move(f)), id, name); + return *this; + } + +} } diff --git a/src/Provisioning/Ops.h b/src/Provisioning/Ops.h new file mode 100644 index 0000000..b1f2695 --- /dev/null +++ b/src/Provisioning/Ops.h @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#pragma once + +#include + +namespace RNS { namespace Provisioning { + + // Operation IDs. Identical ids are used in both requests and responses + // (response of op X always echoes X unless an error occurred, in which + // case the response op is Error). + enum class Op : uint8_t { + GetSchema = 1, + GetInfo = 2, + GetCapabilities = 3, + GetState = 4, + SetState = 5, + Commit = 6, + Discard = 7, + FactoryReset = 8, + Ack = 100, + Error = 101, + }; + + // Error codes returned in Error responses. + enum class ErrorCode : uint16_t { + Ok = 0, + MalformedRequest = 1, + UnknownOp = 2, + UnknownNamespace = 3, + UnknownField = 4, + InvalidValue = 5, + ConstraintViolation = 6, + ReadOnly = 7, + StorageError = 8, + NotInitialized = 9, + Internal = 99, + }; + + // Envelope and response keys. Same set on both directions. + namespace Key { + constexpr uint16_t Op = 1; + constexpr uint16_t Seq = 2; + constexpr uint16_t Payload = 3; + + // Error payload keys + constexpr uint16_t ErrorCodeKey = 1; + constexpr uint16_t ErrorMessage = 2; + constexpr uint16_t ErrorNamespace = 3; + constexpr uint16_t ErrorField = 4; + + // Commit/SetState response keys + constexpr uint16_t Applied = 1; + constexpr uint16_t NeedsReboot = 2; + constexpr uint16_t DraftHasReboot = 2; // alias for SetState + constexpr uint16_t FieldErrors = 3; + + // GetState / SetState request flags + constexpr uint16_t NamespaceFilter = 1; + constexpr uint16_t Pending = 2; + constexpr uint16_t State = 3; // the {ns: {field: value}} map + + // GetInfo + constexpr uint16_t FirmwareVersion = 1; + constexpr uint16_t SchemaVersion = 2; + constexpr uint16_t NeedsRebootInfo = 3; + + // GetCapabilities + constexpr uint16_t Namespaces = 1; + + // GetSchema field metadata + constexpr uint16_t SchemaNamespaces = 1; + constexpr uint16_t NsId = 1; + constexpr uint16_t NsName = 2; + constexpr uint16_t NsFields = 3; + constexpr uint16_t FieldId = 1; + constexpr uint16_t FieldName = 2; + constexpr uint16_t FieldType = 3; + constexpr uint16_t FieldFlags = 4; + constexpr uint16_t FieldMinI = 5; + constexpr uint16_t FieldMaxI = 6; + constexpr uint16_t FieldMinF = 7; + constexpr uint16_t FieldMaxF = 8; + constexpr uint16_t FieldMaxLen = 9; + constexpr uint16_t FieldEnumValues = 10; + constexpr uint16_t FieldEnumLabels = 11; + constexpr uint16_t FieldDefault = 12; + } + +} } diff --git a/src/Provisioning/Provisioning.cpp b/src/Provisioning/Provisioning.cpp new file mode 100644 index 0000000..5b14e84 --- /dev/null +++ b/src/Provisioning/Provisioning.cpp @@ -0,0 +1,685 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#include "Provisioning.h" +#include "Codec.h" + +#include "../Log.h" + +#include + +namespace RNS { namespace Provisioning { + + // Defined in BuiltinNamespaces.cpp. + void register_builtin_namespaces(Manager& p); + + // --------------------------------------------------------------------- + // Singleton + // --------------------------------------------------------------------- + + Manager& Manager::instance() { + static Manager inst; + return inst; + } + + void Manager::begin(const char* storage_root) { + if (_started) return; + // Register library-side namespaces before mounting storage so that + // load_all() has a Registry to overlay onto. + register_builtin_namespaces(*this); +#ifdef RNS_USE_FS + _storage.reset(new Storage(storage_root ? storage_root : "/config")); + _storage->load_all(_registry); + // Push the just-loaded working values into the runtime statics via + // each field's setter. Without this step, persistence is decorative — + // committed flash values would reload into the Provisioning map but + // never reach consumers like Reticulum::link_mtu_discovery(). + apply_loaded_to_runtime(); +#else + (void)storage_root; +#endif + _needs_reboot = false; + _started = true; + } + + void Manager::apply_loaded_to_runtime() { + for (const auto& ns_ptr : _registry.namespaces()) { + for (const Field& f : ns_ptr->fields()) { + if (f.has_flag(FF_READ_ONLY)) continue; + if (!f.setter) continue; + Value v = ns_ptr->working(f.id); + if (v.is_none()) continue; + // Skip values that match the field's declared default — + // those came from register_builtin_namespaces seeding, not + // from disk. The runtime static is already at that default, + // so firing the setter would be a redundant no-op (and + // would also confuse tests / metrics that count setter + // invocations). Only true disk overrides should propagate. + if (v == f.default_value) continue; + try { + f.setter(v); + } + catch (const std::exception& e) { + ERRORF("Manager::apply_loaded_to_runtime: setter for " + "namespace %u field %u threw: %s", + ns_ptr->id(), f.id, e.what()); + } + } + } + } + + void Manager::end() { + _storage.reset(); + _registry.clear(); + _needs_reboot = false; + _started = false; + } + + NamespaceBuilder Manager::namespace_(const char* name, uint16_t id) { + Namespace* ns = _registry.add_namespace(id, name); + if (!ns) { + // Returning a builder over an existing namespace lets callers + // recover after a duplicate registration attempt; we still warn. + ns = _registry.find(id); + if (!ns && name) ns = _registry.find(name); + WARNINGF("Provisioning::namespace_: namespace id=%u name=\"%s\" already exists; appending to it", + id, name ? name : ""); + } + return NamespaceBuilder(ns); + } + + // --------------------------------------------------------------------- + // Direct accessors + // --------------------------------------------------------------------- + + Value Manager::field(uint16_t ns_id, uint16_t field_id, Source src) const { + const Namespace* ns = _registry.find(ns_id); + if (!ns) return {}; + switch (src) { + case Source::Working: + return ns->working(field_id); + case Source::Draft: { + Value v; + if (ns->draft(field_id, v)) return v; + return {}; + } + case Source::Effective: { + Value v; + if (ns->draft(field_id, v)) return v; + return ns->working(field_id); + } + } + return {}; + } + + bool Manager::field(uint16_t ns_id, uint16_t field_id, const Value& v) { + Namespace* ns = _registry.find(ns_id); + if (!ns) return false; + return ns->set_draft(field_id, v); + } + + Value Manager::field(const char* ns_name, const char* field_name, Source src) const { + const Namespace* ns = _registry.find(ns_name); + if (!ns) return {}; + const Field* f = ns->find_field(field_name); + if (!f) return {}; + return field(ns->id(), f->id, src); + } + + bool Manager::field(const char* ns_name, const char* field_name, const Value& v) { + Namespace* ns = _registry.find(ns_name); + if (!ns) return false; + const Field* f = ns->find_field(field_name); + if (!f) return false; + return ns->set_draft(f->id, v); + } + + bool Manager::commit(uint16_t ns_id) { + auto do_one = [&](Namespace& ns) -> bool { + bool any_reboot = false; + // Collect ids first; commit_one() mutates _draft. + std::vector ids; + for (const Field& f : ns.fields()) { + if (ns.has_draft(f.id)) ids.push_back(f.id); + } + for (uint16_t id : ids) { + auto outcome = ns.commit_one(id); + if (outcome == Namespace::CommitOutcome::AppliedReboot) any_reboot = true; + } + bool ok = true; + if (_storage) ok = _storage->save_namespace(ns); + if (any_reboot) set_reboot_flag(true); + return ok; + }; + if (ns_id == 0) { + bool ok = true; + for (const auto& ns_ptr : _registry.namespaces()) ok = do_one(*ns_ptr) && ok; + return ok; + } + Namespace* ns = _registry.find(ns_id); + if (!ns) return false; + return do_one(*ns); + } + + bool Manager::discard(uint16_t ns_id) { + if (ns_id == 0) { + for (const auto& ns_ptr : _registry.namespaces()) ns_ptr->clear_draft(); + return true; + } + Namespace* ns = _registry.find(ns_id); + if (!ns) return false; + ns->clear_draft(); + return true; + } + + bool Manager::commit(const char* ns_name) { + Namespace* ns = _registry.find(ns_name); + if (!ns) return false; + return commit(ns->id()); + } + + bool Manager::discard(const char* ns_name) { + Namespace* ns = _registry.find(ns_name); + if (!ns) return false; + return discard(ns->id()); + } + + bool Manager::factory_reset() { + // Drop drafts and reset working to defaults. + for (const auto& ns_ptr : _registry.namespaces()) { + ns_ptr->clear_draft(); + for (const Field& f : ns_ptr->fields()) { + ns_ptr->put_working(f.id, f.default_value); + } + ns_ptr->mark_clean(); + } + bool ok = true; + if (_storage) ok = _storage->factory_reset(_registry); + _needs_reboot = false; + return ok; + } + + bool Manager::draft_has_reboot() const { + for (const auto& ns_ptr : _registry.namespaces()) { + if (ns_ptr->draft_has_reboot()) return true; + } + return false; + } + + void Manager::set_reboot_flag(bool any_reboot_applied) { + const bool was = _needs_reboot; + if (any_reboot_applied) _needs_reboot = true; + if (!was && _needs_reboot && _on_reboot) { + try { _on_reboot(); } + catch (const std::exception& e) { + ERRORF("Provisioning::on_reboot_requested callback threw: %s", e.what()); + } + } + } + + // --------------------------------------------------------------------- + // Wire codec + // --------------------------------------------------------------------- + + // Each request and response is a MsgPack array of three elements: + // [ op_id (uint), seq (uint), payload ] + // where payload is op-specific (often a map; nil if not used). + + static Bytes pack_response(uint8_t op_id, uint64_t seq, const std::function& pack_payload) { + MsgPack::Packer packer; + packer.serialize(MsgPack::arr_size_t(3)); + packer.serialize((uint8_t)op_id); + packer.serialize((uint64_t)seq); + if (pack_payload) pack_payload(packer); + else { MsgPack::object::nil_t n; packer.serialize(n); } + return Bytes(packer.data(), packer.size()); + } + + Bytes Manager::encode_error(uint8_t op_id, uint64_t seq, ErrorCode code, const char* msg) { + return pack_response((uint8_t)Op::Error, seq, [&](MsgPack::Packer& p) { + p.serialize(MsgPack::map_size_t(msg ? 2 : 1)); + p.serialize((uint16_t)Key::ErrorCodeKey); + p.serialize((uint16_t)code); + if (msg) { + p.serialize((uint16_t)Key::ErrorMessage); + p.serialize(msg); + } + }); + } + + Bytes Manager::encode_ack(uint8_t op_id, uint64_t seq) { + return pack_response(op_id, seq, nullptr); + } + + // Read a uint key into 'key'. Returns false if cursor isn't on a uint. + static bool read_uint_key(MsgPack::Unpacker& u, uint16_t& key) { + if (!(u.isUInt() || u.isInt())) return false; + int64_t v = 0; + u.deserialize(v); + key = (uint16_t)v; + return true; + } + + // Skip the next value in the unpacker. Forward-declared/duplicate of the + // helper in Codec.cpp so this TU is self-contained. + static void skip_value(MsgPack::Unpacker& u) { + if (u.isNil()) { MsgPack::object::nil_t n; u.deserialize(n); } + else if (u.isBool()) { bool b; u.deserialize(b); } + else if (u.isUInt() || u.isInt()) { int64_t i; u.deserialize(i); } + else if (u.isFloat32() || u.isFloat64()) { double d; u.deserialize(d); } + else if (u.isStr()) { MsgPack::str_t s; u.deserialize(s); } + else if (u.isBin()) { MsgPack::bin_t b; u.deserialize(b); } + else if (u.isArray()) { + const size_t n = u.unpackArraySize(); + for (size_t i = 0; i < n; ++i) skip_value(u); + } + else if (u.isMap()) { + const size_t n = u.unpackMapSize(); + for (size_t i = 0; i < n; ++i) { skip_value(u); skip_value(u); } + } + } + + Bytes Manager::op_get_info(uint64_t seq) { + return pack_response((uint8_t)Op::GetInfo, seq, [&](MsgPack::Packer& p) { + p.serialize(MsgPack::map_size_t(3)); + p.serialize((uint16_t)Key::FirmwareVersion); p.serialize("microReticulum"); + p.serialize((uint16_t)Key::SchemaVersion); p.serialize((uint16_t)Manager::SCHEMA_VERSION); + p.serialize((uint16_t)Key::NeedsRebootInfo); p.serialize((bool)_needs_reboot); + }); + } + + Bytes Manager::op_get_capabilities(uint64_t seq) { + return pack_response((uint8_t)Op::GetCapabilities, seq, [&](MsgPack::Packer& p) { + const auto& nss = _registry.namespaces(); + p.serialize(MsgPack::arr_size_t(nss.size())); + for (const auto& ns_ptr : nss) { + p.serialize((uint16_t)ns_ptr->id()); + } + }); + } + + static void pack_field_default(MsgPack::Packer& p, const Field& f) { + switch (f.type) { + case Type::None: { MsgPack::object::nil_t n; p.serialize(n); return; } + case Type::Bool: p.serialize((bool)f.default_value.as_bool()); return; + case Type::Int: + case Type::Enum: p.serialize((int64_t)f.default_value.as_int()); return; + case Type::Float: p.serialize((double)f.default_value.as_float()); return; + case Type::String: p.serialize(f.default_value.as_string().c_str()); return; + case Type::Bytes: { + const Bytes& b = f.default_value.as_bytes(); + MsgPack::bin_t bin; + if (b.size() > 0) { bin.resize(b.size()); memcpy(bin.data(), b.data(), b.size()); } + p.serialize(bin); + return; + } + } + } + + static size_t schema_field_entries(const Field& f) { + // id, name, type, flags, default + size_t n = 5; + if (f.type == Type::Int && f.constraint.has_range) n += 2; + if (f.type == Type::Float && f.constraint.has_range) n += 2; + if ((f.type == Type::String || f.type == Type::Bytes) && f.constraint.max_len > 0) n += 1; + if (f.type == Type::Enum) { + if (!f.constraint.enum_values.empty()) n += 1; + if (!f.constraint.enum_labels.empty()) n += 1; + } + return n; + } + + Bytes Manager::op_get_schema(uint64_t seq) { + return pack_response((uint8_t)Op::GetSchema, seq, [&](MsgPack::Packer& p) { + const auto& nss = _registry.namespaces(); + p.serialize(MsgPack::arr_size_t(nss.size())); + for (const auto& ns_ptr : nss) { + const Namespace& ns = *ns_ptr; + // Each namespace is [id, name, [field-maps]]. + p.serialize(MsgPack::arr_size_t(3)); + p.serialize((uint16_t)ns.id()); + p.serialize(ns.name().c_str()); + const auto& fields = ns.fields(); + p.serialize(MsgPack::arr_size_t(fields.size())); + for (const Field& f : fields) { + p.serialize(MsgPack::map_size_t(schema_field_entries(f))); + p.serialize((uint16_t)Key::FieldId); p.serialize((uint16_t)f.id); + p.serialize((uint16_t)Key::FieldName); p.serialize(f.name.c_str()); + p.serialize((uint16_t)Key::FieldType); p.serialize((uint8_t)f.type); + p.serialize((uint16_t)Key::FieldFlags); p.serialize((uint8_t)f.flags); + p.serialize((uint16_t)Key::FieldDefault); pack_field_default(p, f); + if (f.type == Type::Int && f.constraint.has_range) { + p.serialize((uint16_t)Key::FieldMinI); p.serialize((int64_t)f.constraint.imin); + p.serialize((uint16_t)Key::FieldMaxI); p.serialize((int64_t)f.constraint.imax); + } + if (f.type == Type::Float && f.constraint.has_range) { + p.serialize((uint16_t)Key::FieldMinF); p.serialize((double)f.constraint.fmin); + p.serialize((uint16_t)Key::FieldMaxF); p.serialize((double)f.constraint.fmax); + } + if ((f.type == Type::String || f.type == Type::Bytes) && f.constraint.max_len > 0) { + p.serialize((uint16_t)Key::FieldMaxLen); p.serialize((uint64_t)f.constraint.max_len); + } + if (f.type == Type::Enum) { + if (!f.constraint.enum_values.empty()) { + p.serialize((uint16_t)Key::FieldEnumValues); + p.serialize(MsgPack::arr_size_t(f.constraint.enum_values.size())); + for (int64_t v : f.constraint.enum_values) p.serialize(v); + } + if (!f.constraint.enum_labels.empty()) { + p.serialize((uint16_t)Key::FieldEnumLabels); + p.serialize(MsgPack::arr_size_t(f.constraint.enum_labels.size())); + for (const auto& s : f.constraint.enum_labels) p.serialize(s.c_str()); + } + } + } + } + }); + } + + static void pack_state_value(MsgPack::Packer& p, Type t, const Value& v) { + (void)t; + Codec::pack_value(p, v); + } + + Bytes Manager::op_get_state(uint64_t seq, void* unpacker_v) { + MsgPack::Unpacker* up = (MsgPack::Unpacker*)unpacker_v; + std::unordered_set ns_filter; + bool has_filter = false; + bool pending = false; + // Optional payload map: {1: [ns_filter], 2: pending} + if (up && up->isMap()) { + const size_t n = up->unpackMapSize(); + for (size_t i = 0; i < n; ++i) { + uint16_t key; + if (!read_uint_key(*up, key)) { skip_value(*up); continue; } + if (key == Key::NamespaceFilter) { + if (up->isArray()) { + const size_t m = up->unpackArraySize(); + for (size_t j = 0; j < m; ++j) { + if (up->isUInt() || up->isInt()) { + int64_t v; up->deserialize(v); + ns_filter.insert((uint16_t)v); + } + else skip_value(*up); + } + has_filter = true; + } + else skip_value(*up); + } + else if (key == Key::Pending) { + if (up->isBool()) up->deserialize(pending); + else skip_value(*up); + } + else skip_value(*up); + } + } + else if (up) skip_value(*up); + + return pack_response((uint8_t)Op::GetState, seq, [&](MsgPack::Packer& p) { + std::vector ns_list; + for (const auto& ns_ptr : _registry.namespaces()) { + if (has_filter && ns_filter.count(ns_ptr->id()) == 0) continue; + ns_list.push_back(ns_ptr.get()); + } + p.serialize(MsgPack::map_size_t(ns_list.size())); + for (const Namespace* ns : ns_list) { + p.serialize((uint16_t)ns->id()); + // Count entries first (skip SECRET fields). + size_t entries = 0; + for (const Field& f : ns->fields()) { + if (f.has_flag(FF_SECRET)) continue; + Value v = ns->working(f.id); + if (pending) { + Value d; + if (ns->draft(f.id, d)) v = d; + } + if (!v.is_none()) ++entries; + } + p.serialize(MsgPack::map_size_t(entries)); + for (const Field& f : ns->fields()) { + if (f.has_flag(FF_SECRET)) continue; + Value v = ns->working(f.id); + if (pending) { + Value d; + if (ns->draft(f.id, d)) v = d; + } + if (v.is_none()) continue; + p.serialize((uint16_t)f.id); + pack_state_value(p, f.type, v); + } + } + }); + } + + Bytes Manager::op_set_state(uint64_t seq, void* unpacker_v) { + MsgPack::Unpacker* up = (MsgPack::Unpacker*)unpacker_v; + if (!up || !up->isMap()) { + return encode_error((uint8_t)Op::SetState, seq, ErrorCode::MalformedRequest, "expected map payload"); + } + const size_t n_ns = up->unpackMapSize(); + size_t applied = 0; + struct Err { uint16_t ns_id; uint16_t field_id; uint16_t code; }; + std::vector errors; + for (size_t i = 0; i < n_ns; ++i) { + if (!(up->isUInt() || up->isInt())) { skip_value(*up); skip_value(*up); continue; } + int64_t k1 = 0; up->deserialize(k1); + const uint16_t ns_id = (uint16_t)k1; + Namespace* ns = _registry.find(ns_id); + if (!up->isMap()) { + // Inner must be a map of {field_id: value} + skip_value(*up); + if (!ns) errors.push_back({ns_id, 0, (uint16_t)ErrorCode::UnknownNamespace}); + else errors.push_back({ns_id, 0, (uint16_t)ErrorCode::MalformedRequest}); + continue; + } + const size_t n_fields = up->unpackMapSize(); + for (size_t j = 0; j < n_fields; ++j) { + if (!(up->isUInt() || up->isInt())) { skip_value(*up); skip_value(*up); continue; } + int64_t k2 = 0; up->deserialize(k2); + const uint16_t fid = (uint16_t)k2; + if (!ns) { + skip_value(*up); + errors.push_back({ns_id, fid, (uint16_t)ErrorCode::UnknownNamespace}); + continue; + } + const Field* f = ns->find_field(fid); + if (!f) { + skip_value(*up); + errors.push_back({ns_id, fid, (uint16_t)ErrorCode::UnknownField}); + continue; + } + Value v; + if (!Codec::unpack_value(*up, f->type, v)) { + errors.push_back({ns_id, fid, (uint16_t)ErrorCode::InvalidValue}); + continue; + } + if (f->has_flag(FF_READ_ONLY)) { + errors.push_back({ns_id, fid, (uint16_t)ErrorCode::ReadOnly}); + continue; + } + if (!ns->set_draft(fid, v)) { + errors.push_back({ns_id, fid, (uint16_t)ErrorCode::ConstraintViolation}); + continue; + } + ++applied; + } + } + return pack_response((uint8_t)Op::SetState, seq, [&](MsgPack::Packer& p) { + p.serialize(MsgPack::map_size_t(errors.empty() ? 2 : 3)); + p.serialize((uint16_t)Key::Applied); p.serialize((uint64_t)applied); + p.serialize((uint16_t)Key::DraftHasReboot); p.serialize((bool)draft_has_reboot()); + if (!errors.empty()) { + p.serialize((uint16_t)Key::FieldErrors); + p.serialize(MsgPack::arr_size_t(errors.size())); + for (const Err& e : errors) { + p.serialize(MsgPack::arr_size_t(3)); + p.serialize((uint16_t)e.ns_id); + p.serialize((uint16_t)e.field_id); + p.serialize((uint16_t)e.code); + } + } + }); + } + + Bytes Manager::op_commit(uint64_t seq, void* unpacker_v) { + MsgPack::Unpacker* up = (MsgPack::Unpacker*)unpacker_v; + std::vector filter; + bool has_filter = false; + if (up && up->isArray()) { + const size_t n = up->unpackArraySize(); + has_filter = true; + for (size_t i = 0; i < n; ++i) { + if (up->isUInt() || up->isInt()) { + int64_t v; up->deserialize(v); + filter.push_back((uint16_t)v); + } + else skip_value(*up); + } + } + else if (up) skip_value(*up); + + size_t applied_total = 0; + bool any_reboot = false; + + auto do_one = [&](Namespace& ns) { + std::vector ids; + for (const Field& f : ns.fields()) if (ns.has_draft(f.id)) ids.push_back(f.id); + for (uint16_t id : ids) { + auto outcome = ns.commit_one(id); + switch (outcome) { + case Namespace::CommitOutcome::AppliedLive: + ++applied_total; break; + case Namespace::CommitOutcome::AppliedReboot: + ++applied_total; any_reboot = true; break; + default: break; + } + } + if (_storage) _storage->save_namespace(ns); + }; + + if (has_filter) { + for (uint16_t id : filter) { + Namespace* ns = _registry.find(id); + if (ns) do_one(*ns); + } + } + else { + for (const auto& ns_ptr : _registry.namespaces()) do_one(*ns_ptr); + } + set_reboot_flag(any_reboot); + + return pack_response((uint8_t)Op::Commit, seq, [&](MsgPack::Packer& p) { + p.serialize(MsgPack::map_size_t(2)); + p.serialize((uint16_t)Key::Applied); p.serialize((uint64_t)applied_total); + p.serialize((uint16_t)Key::NeedsReboot); p.serialize((bool)_needs_reboot); + }); + } + + Bytes Manager::op_discard(uint64_t seq, void* unpacker_v) { + MsgPack::Unpacker* up = (MsgPack::Unpacker*)unpacker_v; + std::vector filter; + bool has_filter = false; + if (up && up->isArray()) { + const size_t n = up->unpackArraySize(); + has_filter = true; + for (size_t i = 0; i < n; ++i) { + if (up->isUInt() || up->isInt()) { + int64_t v; up->deserialize(v); + filter.push_back((uint16_t)v); + } + else skip_value(*up); + } + } + else if (up) skip_value(*up); + + size_t cleared = 0; + auto count_and_clear = [&](Namespace& ns) { + for (const Field& f : ns.fields()) if (ns.has_draft(f.id)) ++cleared; + ns.clear_draft(); + }; + if (has_filter) { + for (uint16_t id : filter) { + Namespace* ns = _registry.find(id); + if (ns) count_and_clear(*ns); + } + } + else { + for (const auto& ns_ptr : _registry.namespaces()) count_and_clear(*ns_ptr); + } + + return pack_response((uint8_t)Op::Discard, seq, [&](MsgPack::Packer& p) { + p.serialize(MsgPack::map_size_t(1)); + p.serialize((uint16_t)Key::Applied); p.serialize((uint64_t)cleared); + }); + } + + Bytes Manager::op_factory_reset(uint64_t seq) { + factory_reset(); + return pack_response((uint8_t)Op::FactoryReset, seq, [&](MsgPack::Packer& p) { + p.serialize(MsgPack::map_size_t(1)); + p.serialize((uint16_t)Key::NeedsReboot); p.serialize((bool)_needs_reboot); + }); + } + + Bytes Manager::handle_message(const Bytes& request) { + if (!_started) { + return encode_error(0, 0, ErrorCode::NotInitialized, "Provisioning::begin not called"); + } + MsgPack::Unpacker up; + up.feed(request.data(), request.size()); + + if (!up.isArray()) { + return encode_error(0, 0, ErrorCode::MalformedRequest, "envelope must be array"); + } + const size_t arr_size = up.unpackArraySize(); + if (arr_size < 1) { + return encode_error(0, 0, ErrorCode::MalformedRequest, "envelope empty"); + } + + // Element 1: op id + if (!(up.isUInt() || up.isInt())) { + return encode_error(0, 0, ErrorCode::MalformedRequest, "op id must be uint"); + } + int64_t op_raw = 0; + up.deserialize(op_raw); + const uint8_t op_id = (uint8_t)op_raw; + + // Element 2: seq (optional) + uint64_t seq = 0; + if (arr_size >= 2) { + if (up.isUInt() || up.isInt()) { + int64_t s = 0; up.deserialize(s); + seq = (uint64_t)s; + } + else skip_value(up); + } + + // Element 3: payload (optional). May be nil. + const bool has_payload = (arr_size >= 3); + + switch ((Op)op_id) { + case Op::GetSchema: if (has_payload) skip_value(up); return op_get_schema(seq); + case Op::GetInfo: if (has_payload) skip_value(up); return op_get_info(seq); + case Op::GetCapabilities: if (has_payload) skip_value(up); return op_get_capabilities(seq); + case Op::GetState: return op_get_state(seq, has_payload ? &up : nullptr); + case Op::SetState: return op_set_state(seq, has_payload ? &up : nullptr); + case Op::Commit: return op_commit(seq, has_payload ? &up : nullptr); + case Op::Discard: return op_discard(seq, has_payload ? &up : nullptr); + case Op::FactoryReset: if (has_payload) skip_value(up); return op_factory_reset(seq); + default: + return encode_error(op_id, seq, ErrorCode::UnknownOp, "unrecognised op id"); + } + } + +} } diff --git a/src/Provisioning/Provisioning.h b/src/Provisioning/Provisioning.h new file mode 100644 index 0000000..96a6ccb --- /dev/null +++ b/src/Provisioning/Provisioning.h @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#pragma once + +#include "Registry.h" +#include "Storage.h" +#include "Value.h" +#include "Field.h" +#include "Ops.h" + +#include "../Bytes.h" + +#include +#include +#include +#include + +namespace RNS { namespace Provisioning { + + class Manager; + + // Fluent builder for app-side namespace registration. + // + // Manager::instance() + // .namespace_("radio", 100) + // .field_float("frequency", 1, FF_REBOOT_REQUIRED, 915.0e6, 100e6, 1e9, + // [&](const Value& v) { radio.frequency(v.as_float()); return true; }) + // .field_int("sf", 4, FF_REBOOT_REQUIRED, 8, 7, 12) + // .end(); + class NamespaceBuilder { + + public: + NamespaceBuilder(Namespace* ns) : _ns(ns) {} + + NamespaceBuilder& field_bool(const char* name, uint16_t id, uint8_t flags, + bool default_value, SetterFn setter = nullptr); + + NamespaceBuilder& field_int(const char* name, uint16_t id, uint8_t flags, + int64_t default_value, int64_t imin, int64_t imax, SetterFn setter = nullptr); + + NamespaceBuilder& field_int(const char* name, uint16_t id, uint8_t flags, + int64_t default_value, SetterFn setter = nullptr); + + NamespaceBuilder& field_float(const char* name, uint16_t id, uint8_t flags, + double default_value, double fmin, double fmax, SetterFn setter = nullptr); + + NamespaceBuilder& field_float(const char* name, uint16_t id, uint8_t flags, + double default_value, SetterFn setter = nullptr); + + NamespaceBuilder& field_string(const char* name, uint16_t id, uint8_t flags, + const char* default_value, size_t max_len = 0, SetterFn setter = nullptr); + + NamespaceBuilder& field_bytes(const char* name, uint16_t id, uint8_t flags, + const Bytes& default_value, size_t max_len = 0, SetterFn setter = nullptr); + + NamespaceBuilder& field_enum(const char* name, uint16_t id, uint8_t flags, + int64_t default_value, + std::vector values, + std::vector labels, + SetterFn setter = nullptr); + + // Optional terminator for readability; the chain is also fine + // without it (NamespaceBuilder is a value, not a guard). + void end() {} + + private: + Namespace* _ns; + }; + + // Singleton orchestrating schema, storage, working/draft, wire codec. + class Manager { + + public: + enum class Source : uint8_t { + Working = 0, // current effective value + Draft = 1, // pending edit (none if no draft entry) + Effective = 2, // working overlaid with draft + }; + + using RebootRequestedCallback = std::function; + + static Manager& instance(); + + // Idempotent: must be called once after the filesystem is registered. + // storage_root defaults to "/config". + void begin(const char* storage_root = "/config"); + void end(); + bool started() const { return _started; } + + // Register an app-defined namespace. + NamespaceBuilder namespace_(const char* name, uint16_t id); + + // Register a built-in namespace (same backing call as namespace_ + // but named for symmetry with the RNS_PROVISION_NAMESPACE macros). + NamespaceBuilder register_namespace(const char* name, uint16_t id) { + return namespace_(name, id); + } + + // -- Wire entry point -------------------------------------------- + Bytes handle_message(const Bytes& request); + + // -- Direct accessors, by id ------------------------------------- + Value field(uint16_t ns_id, uint16_t field_id, Source = Source::Working) const; + bool field(uint16_t ns_id, uint16_t field_id, const Value& v); + bool commit(uint16_t ns_id = 0); + bool discard(uint16_t ns_id = 0); + + // -- Direct accessors, by name ----------------------------------- + Value field(const char* ns_name, const char* field_name, Source = Source::Working) const; + bool field(const char* ns_name, const char* field_name, const Value& v); + bool commit(const char* ns_name); + bool discard(const char* ns_name); + + bool factory_reset(); + + // -- State queries ----------------------------------------------- + bool needs_reboot() const { return _needs_reboot; } + bool draft_has_reboot() const; + + void on_reboot_requested(RebootRequestedCallback cb) { _on_reboot = std::move(cb); } + + // -- Introspection (for tests, debug, host bindings) -------------- + Registry& registry() { return _registry; } + const Registry& registry() const { return _registry; } + Storage* storage() { return _storage.get(); } + + // -- Schema/version constants ------------------------------------ + static constexpr uint16_t SCHEMA_VERSION = 1; + + private: + Manager() = default; + Manager(const Manager&) = delete; + Manager& operator=(const Manager&) = delete; + + Registry _registry; + std::unique_ptr _storage; + bool _started = false; + bool _needs_reboot = false; + RebootRequestedCallback _on_reboot; + + // Internal helpers for the wire dispatch path. + Bytes encode_error(uint8_t op_id, uint64_t seq, ErrorCode code, const char* msg = nullptr); + Bytes encode_ack(uint8_t op_id, uint64_t seq); + + // Per-op response builders. The unpacker is positioned at the + // envelope's payload value (which may be nil / map / array depending + // on op). The returned Bytes is a fully framed response. + class Unpacker; // forward; defined in .cpp + Bytes op_get_schema(uint64_t seq); + Bytes op_get_info(uint64_t seq); + Bytes op_get_capabilities(uint64_t seq); + Bytes op_get_state(uint64_t seq, void* unpacker); + Bytes op_set_state(uint64_t seq, void* unpacker); + Bytes op_commit(uint64_t seq, void* unpacker); + Bytes op_discard(uint64_t seq, void* unpacker); + Bytes op_factory_reset(uint64_t seq); + + void set_reboot_flag(bool any_reboot_applied); + + // After Storage::load_all() has overlaid disk values into the + // working map, walk every field with a non-null setter and push + // the working value through. Fires for both FF_LIVE_APPLY and + // FF_REBOOT_REQUIRED fields — at boot the reboot has by definition + // already happened, so REBOOT_REQUIRED's deferred-apply is + // satisfied here. Post-boot commits keep the original behaviour + // (REBOOT_REQUIRED setters do not fire on commit). + void apply_loaded_to_runtime(); + }; + +} } diff --git a/src/Provisioning/Registry.cpp b/src/Provisioning/Registry.cpp new file mode 100644 index 0000000..d7a05ae --- /dev/null +++ b/src/Provisioning/Registry.cpp @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#include "Registry.h" + +namespace RNS { namespace Provisioning { + + Namespace* Registry::add_namespace(uint16_t id, const char* name) { + if (_id_index.count(id) != 0) return nullptr; + std::string sname = name ? name : ""; + if (!sname.empty() && _name_index.count(sname) != 0) return nullptr; + size_t idx = _namespaces.size(); + _namespaces.push_back(std::unique_ptr(new Namespace(id, name))); + _id_index[id] = idx; + if (!sname.empty()) _name_index[sname] = idx; + return _namespaces.back().get(); + } + + Namespace* Registry::find(uint16_t id) { + auto it = _id_index.find(id); + if (it == _id_index.end()) return nullptr; + return _namespaces[it->second].get(); + } + + Namespace* Registry::find(const char* name) { + if (!name) return nullptr; + auto it = _name_index.find(name); + if (it == _name_index.end()) return nullptr; + return _namespaces[it->second].get(); + } + + const Namespace* Registry::find(uint16_t id) const { + auto it = _id_index.find(id); + if (it == _id_index.end()) return nullptr; + return _namespaces[it->second].get(); + } + + const Namespace* Registry::find(const char* name) const { + if (!name) return nullptr; + auto it = _name_index.find(name); + if (it == _name_index.end()) return nullptr; + return _namespaces[it->second].get(); + } + + void Registry::clear() { + _namespaces.clear(); + _id_index.clear(); + _name_index.clear(); + } + +} } diff --git a/src/Provisioning/Registry.h b/src/Provisioning/Registry.h new file mode 100644 index 0000000..97dfb66 --- /dev/null +++ b/src/Provisioning/Registry.h @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#pragma once + +#include "Namespace.h" + +#include +#include +#include +#include + +namespace RNS { namespace Provisioning { + + class Registry { + + public: + Registry() = default; + + // Create a new namespace. Returns nullptr if id or name is taken. + Namespace* add_namespace(uint16_t id, const char* name); + + Namespace* find(uint16_t id); + Namespace* find(const char* name); + const Namespace* find(uint16_t id) const; + const Namespace* find(const char* name) const; + + const std::vector>& namespaces() const { return _namespaces; } + + void clear(); + + private: + std::vector> _namespaces; + std::unordered_map _id_index; + std::unordered_map _name_index; + }; + +} } diff --git a/src/Provisioning/Storage.cpp b/src/Provisioning/Storage.cpp new file mode 100644 index 0000000..9ef3a01 --- /dev/null +++ b/src/Provisioning/Storage.cpp @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#include "Storage.h" +#include "Codec.h" + +#include "../Bytes.h" +#include "../Log.h" +#include "../Utilities/OS.h" + +namespace RNS { namespace Provisioning { + + std::string Storage::file_path(const Namespace& ns) const { + return _root + "/" + ns.name() + ".msgpack"; + } + + std::string Storage::tmp_path(const Namespace& ns) const { + return _root + "/" + ns.name() + ".msgpack.tmp"; + } + + bool Storage::ensure_directory() { + try { + if (Utilities::OS::directory_exists(_root.c_str())) return true; + return Utilities::OS::create_directory(_root.c_str()); + } + catch (const std::exception& e) { + ERRORF("Storage::ensure_directory: %s", e.what()); + return false; + } + } + + bool Storage::load_namespace(Namespace& ns) { + const std::string tmp = tmp_path(ns); + const std::string final = file_path(ns); + + // Discard any stale .tmp from an interrupted save. + try { + if (Utilities::OS::file_exists(tmp.c_str())) { + Utilities::OS::remove_file(tmp.c_str()); + } + } + catch (...) { /* best-effort */ } + + try { + if (!Utilities::OS::file_exists(final.c_str())) return false; + } + catch (...) { return false; } + + Bytes raw; + try { + size_t n = Utilities::OS::read_file(final.c_str(), raw); + if (n == 0) return false; + } + catch (const std::exception& e) { + ERRORF("Storage::load_namespace: read failed: %s", e.what()); + return false; + } + + MsgPack::Unpacker unpacker; + unpacker.feed(raw.data(), raw.size()); + if (!Codec::unpack_namespace_working(unpacker, ns)) { + WARNINGF("Storage::load_namespace: malformed file for namespace %s", ns.name().c_str()); + return false; + } + ns.mark_clean(); + return true; + } + + size_t Storage::load_all(Registry& registry) { + if (!ensure_directory()) { + WARNINGF("Storage::load_all: directory %s unavailable", _root.c_str()); + return 0; + } + size_t loaded = 0; + for (const auto& ns_ptr : registry.namespaces()) { + if (load_namespace(*ns_ptr)) ++loaded; + } + return loaded; + } + + bool Storage::save_namespace(Namespace& ns) { + if (!ns.dirty_for_persist()) return true; + return save_namespace_force(ns); + } + + bool Storage::save_namespace_force(Namespace& ns) { + if (!ensure_directory()) return false; + + MsgPack::Packer packer; + if (!Codec::pack_namespace_working(packer, ns)) return false; + Bytes encoded(packer.data(), packer.size()); + + const std::string tmp = tmp_path(ns); + const std::string final = file_path(ns); + + try { + size_t wrote = Utilities::OS::write_file(tmp.c_str(), encoded); + if (wrote != encoded.size()) { + ERRORF("Storage::save: short write for %s (%zu/%zu)", + ns.name().c_str(), wrote, encoded.size()); + Utilities::OS::remove_file(tmp.c_str()); + return false; + } + // Rename overwrites on POSIX; on Arduino flash backends the + // adapter handles delete-then-rename internally. + if (!Utilities::OS::rename_file(tmp.c_str(), final.c_str())) { + // Fallback: remove existing final and retry rename, since + // some Arduino FS adapters won't overwrite atomically. + try { Utilities::OS::remove_file(final.c_str()); } catch (...) {} + if (!Utilities::OS::rename_file(tmp.c_str(), final.c_str())) { + ERRORF("Storage::save: rename failed for %s", ns.name().c_str()); + Utilities::OS::remove_file(tmp.c_str()); + return false; + } + } + } + catch (const std::exception& e) { + ERRORF("Storage::save: %s", e.what()); + return false; + } + + ns.mark_clean(); + return true; + } + + bool Storage::remove_namespace(const Namespace& ns) { + const std::string tmp = tmp_path(ns); + const std::string final = file_path(ns); + bool ok = true; + try { + if (Utilities::OS::file_exists(tmp.c_str())) { + ok = Utilities::OS::remove_file(tmp.c_str()) && ok; + } + if (Utilities::OS::file_exists(final.c_str())) { + ok = Utilities::OS::remove_file(final.c_str()) && ok; + } + } + catch (const std::exception& e) { + ERRORF("Storage::remove_namespace: %s", e.what()); + return false; + } + return ok; + } + + bool Storage::factory_reset(const Registry& registry) { + bool ok = true; + for (const auto& ns_ptr : registry.namespaces()) { + ok = remove_namespace(*ns_ptr) && ok; + } + return ok; + } + +} } diff --git a/src/Provisioning/Storage.h b/src/Provisioning/Storage.h new file mode 100644 index 0000000..e3d03fc --- /dev/null +++ b/src/Provisioning/Storage.h @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#pragma once + +#include "Registry.h" + +#include + +namespace RNS { namespace Provisioning { + + // Per-namespace MsgPack files written atomically via .msgpack.tmp + // followed by rename to .msgpack. On load, stale .tmp files are + // removed so a power-loss mid-save leaves the previous file intact. + class Storage { + + public: + explicit Storage(const char* root) : _root(root ? root : "/config") {} + + const std::string& root() const { return _root; } + + // Ensure the storage directory exists. Returns true on success + // (already-exists counts as success). + bool ensure_directory(); + + // Load every namespace's saved file (if present) into its working map. + // Returns the count of files successfully loaded. + size_t load_all(Registry& registry); + + // Save the namespace if its working state has been touched since + // the last clean mark. No-op (returns true) if not dirty. + bool save_namespace(Namespace& ns); + + // Force-save regardless of dirty bit. + bool save_namespace_force(Namespace& ns); + + // Delete the namespace's file (and any stray .tmp). + bool remove_namespace(const Namespace& ns); + + // Remove every namespace file under the configured root. Used by + // FACTORY_RESET. Returns true if all known files were removed. + bool factory_reset(const Registry& registry); + + private: + std::string _root; + + std::string file_path(const Namespace& ns) const; + std::string tmp_path(const Namespace& ns) const; + + bool load_namespace(Namespace& ns); + }; + +} } diff --git a/src/Provisioning/Value.h b/src/Provisioning/Value.h new file mode 100644 index 0000000..fb5af17 --- /dev/null +++ b/src/Provisioning/Value.h @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026 Chad Attermann + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +#pragma once + +#include "../Bytes.h" + +#include +#include + +namespace RNS { namespace Provisioning { + + // Field/value types aligned with MsgPack wire types. + enum class Type : uint8_t { + None = 0, + Bool = 1, + Int = 2, + Float = 3, + String = 4, + Bytes = 5, + Enum = 6, // integer on the wire; schema carries labels + }; + + class Value { + + public: + Value() : _type(Type::None), _i(0), _d(0.0) {} + Value(bool v) : _type(Type::Bool), _i(v ? 1 : 0), _d(0.0) {} + Value(int v) : _type(Type::Int), _i(v), _d(0.0) {} + Value(unsigned int v) : _type(Type::Int), _i((int64_t)v), _d(0.0) {} + Value(long v) : _type(Type::Int), _i((int64_t)v), _d(0.0) {} + Value(unsigned long v) : _type(Type::Int), _i((int64_t)v), _d(0.0) {} + Value(long long v) : _type(Type::Int), _i((int64_t)v), _d(0.0) {} + Value(unsigned long long v) : _type(Type::Int), _i((int64_t)v), _d(0.0) {} + Value(double v) : _type(Type::Float), _i(0), _d(v) {} + Value(float v) : _type(Type::Float), _i(0), _d((double)v) {} + Value(const char* s) : _type(Type::String), _i(0), _d(0.0), _s(s ? s : "") {} + Value(const std::string& s) : _type(Type::String), _i(0), _d(0.0), _s(s) {} + Value(const Bytes& b) : _type(Type::Bytes), _i(0), _d(0.0), _b(b) {} + + // Enum is distinguished from Int only in schema; on the wire and in + // storage it's a positive int. Use this factory when a field is declared + // as Type::Enum so consumers know to look up labels. + static Value make_enum(int64_t v) { + Value out; + out._type = Type::Enum; + out._i = v; + return out; + } + + // Adopt an existing wire-side integer as the given declared type + // (used by the wire codec when decoding into a field whose declared + // type may be Bool / Enum / Int — all of which carry an int). + static Value from_int_as(Type declared_type, int64_t v) { + Value out; + out._type = declared_type; + out._i = v; + return out; + } + + Type type() const { return _type; } + bool is_none() const { return _type == Type::None; } + + bool as_bool() const { return _i != 0; } + int64_t as_int() const { return _i; } + double as_float() const { return _d; } + const std::string& as_string() const { return _s; } + const Bytes& as_bytes() const { return _b; } + + bool operator==(const Value& o) const { + if (_type != o._type) return false; + switch (_type) { + case Type::None: return true; + case Type::Bool: + case Type::Int: + case Type::Enum: return _i == o._i; + case Type::Float: return _d == o._d; + case Type::String: return _s == o._s; + case Type::Bytes: return _b == o._b; + } + return false; + } + bool operator!=(const Value& o) const { return !(*this == o); } + + private: + Type _type; + int64_t _i; + double _d; + std::string _s; + Bytes _b; + }; + +} } diff --git a/src/Reticulum.cpp b/src/Reticulum.cpp index 986c847..50207ce 100644 --- a/src/Reticulum.cpp +++ b/src/Reticulum.cpp @@ -18,6 +18,10 @@ #include "Log.h" #include "Utilities/Memory.h" +#ifdef RNS_USE_PROVISIONING +#include "Provisioning/Provisioning.h" +#endif + //#include #include @@ -196,6 +200,11 @@ void Reticulum::start() { writeTimeOffset(); #endif +#ifdef RNS_USE_PROVISIONING + INFO("Starting Provisioning..."); + Provisioning::Manager::instance().begin(); +#endif + INFO("Starting Transport..."); Transport::start(*this); diff --git a/test/test_provisioning/test_provisioning.cpp b/test/test_provisioning/test_provisioning.cpp new file mode 100644 index 0000000..9b9a8cd --- /dev/null +++ b/test/test_provisioning/test_provisioning.cpp @@ -0,0 +1,606 @@ +#include + +#include + +#include "Provisioning/Provisioning.h" +#include "Provisioning/Codec.h" +#include "Provisioning/Ids.h" + +#include "Utilities/OS.h" +#include "Bytes.h" +#include "Log.h" + +#define MSGPACK_DEBUGLOG_ENABLE 0 +#include + +#include +#include +#include +#include + +using namespace RNS; +using namespace RNS::Provisioning; + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +// Each test uses an isolated directory under /tmp so files from one test +// don't leak into another. The fixture is the per-process counter below. +static int g_test_counter = 0; +static std::string g_test_root; + +static std::string make_test_root() { + char buf[256]; + int pid = (int)getpid(); + snprintf(buf, sizeof(buf), "/tmp/rns_prov_test_%d_%d", pid, ++g_test_counter); + mkdir(buf, 0755); + return std::string(buf); +} + +static void rm_rf(const std::string& path) { + std::string cmd = "rm -rf '" + path + "'"; + int rc = system(cmd.c_str()); + (void)rc; +} + +// A small "custom" namespace used by most tests — independent of the +// built-ins so we don't depend on Reticulum runtime state. +static constexpr uint16_t CUSTOM_NS_ID = 9100; +static constexpr uint16_t CUSTOM_BOOL = 1; +static constexpr uint16_t CUSTOM_INT = 2; +static constexpr uint16_t CUSTOM_FLOAT = 3; +static constexpr uint16_t CUSTOM_STR = 4; +static constexpr uint16_t CUSTOM_BYTES = 5; +static constexpr uint16_t CUSTOM_REBOOT_INT = 6; // FF_REBOOT_REQUIRED + +// Tracks whether the live setter for CUSTOM_INT was invoked, and what +// value it last saw — for live_vs_reboot_apply. +static int g_live_int_setter_count = 0; +static int64_t g_live_int_setter_value = 0; +static int g_reboot_int_setter_count = 0; + +static void reset_setter_counters() { + g_live_int_setter_count = 0; + g_live_int_setter_value = 0; + g_reboot_int_setter_count = 0; +} + +static void register_custom_namespace() { + Manager::instance() + .namespace_("custom", CUSTOM_NS_ID) + .field_bool ("enabled", CUSTOM_BOOL, FF_LIVE_APPLY, false) + .field_int ("level", CUSTOM_INT, FF_LIVE_APPLY, 5, 0, 100, + [](const Value& v) { ++g_live_int_setter_count; g_live_int_setter_value = v.as_int(); return true; }) + .field_float("ratio", CUSTOM_FLOAT, FF_LIVE_APPLY, 0.5, 0.0, 1.0) + .field_string("label", CUSTOM_STR, FF_LIVE_APPLY, "default", 32) + .field_bytes ("blob", CUSTOM_BYTES, FF_LIVE_APPLY, Bytes(), 64) + .field_int ("frequency_hz", CUSTOM_REBOOT_INT, FF_REBOOT_REQUIRED, 915000000, 100000000, 1000000000, + [](const Value& v) { ++g_reboot_int_setter_count; (void)v; return true; }) + .end(); +} + +static void fresh_provisioning(const std::string& root) { + Manager::instance().end(); + register_custom_namespace(); // must register before begin() + Manager::instance().begin(root.c_str()); +} + +void setUp(void) { + g_test_root = make_test_root(); + reset_setter_counters(); +} + +void tearDown(void) { + Manager::instance().end(); + rm_rf(g_test_root); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void test_register_and_lookup(void) { + fresh_provisioning(g_test_root); + auto& reg = Manager::instance().registry(); + + // Built-ins registered automatically. + TEST_ASSERT_NOT_NULL(reg.find(Ns::Reticulum::Id)); + TEST_ASSERT_NOT_NULL(reg.find("reticulum")); + TEST_ASSERT_NOT_NULL(reg.find(Ns::Transport::Id)); + + // Custom namespace registered explicitly. + const Namespace* ns = reg.find(CUSTOM_NS_ID); + TEST_ASSERT_NOT_NULL(ns); + TEST_ASSERT_NOT_NULL(reg.find("custom")); + TEST_ASSERT_NOT_NULL(ns->find_field(CUSTOM_INT)); + TEST_ASSERT_NOT_NULL(ns->find_field("level")); + TEST_ASSERT_NULL(ns->find_field((uint16_t)9999)); +} + +void test_default_values(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + + Value v_bool = p.field(CUSTOM_NS_ID, CUSTOM_BOOL); + TEST_ASSERT_EQUAL(Type::Bool, (int)v_bool.type()); + TEST_ASSERT_FALSE(v_bool.as_bool()); + + Value v_int = p.field(CUSTOM_NS_ID, CUSTOM_INT); + TEST_ASSERT_EQUAL_INT64(5, v_int.as_int()); +} + +void test_set_draft_independent_of_working(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + + TEST_ASSERT_TRUE(p.field(CUSTOM_NS_ID, CUSTOM_INT, Value((int64_t)42))); + // Working still holds default. + TEST_ASSERT_EQUAL_INT64(5, p.field(CUSTOM_NS_ID, CUSTOM_INT, Manager::Source::Working).as_int()); + // Draft holds new. + TEST_ASSERT_EQUAL_INT64(42, p.field(CUSTOM_NS_ID, CUSTOM_INT, Manager::Source::Draft).as_int()); + // Effective overlays. + TEST_ASSERT_EQUAL_INT64(42, p.field(CUSTOM_NS_ID, CUSTOM_INT, Manager::Source::Effective).as_int()); + // Live setter not invoked yet — only fires at commit. + TEST_ASSERT_EQUAL(0, g_live_int_setter_count); +} + +void test_commit_promotes_and_invokes_live_setter(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + + 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_INT64(42, p.field(CUSTOM_NS_ID, CUSTOM_INT).as_int()); + TEST_ASSERT_EQUAL(1, g_live_int_setter_count); + TEST_ASSERT_EQUAL_INT64(42, g_live_int_setter_value); + // Draft was cleared. + TEST_ASSERT_FALSE(p.field(CUSTOM_NS_ID, CUSTOM_INT, Manager::Source::Draft).type() != Type::None); +} + +void test_discard_clears_draft(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + + p.field(CUSTOM_NS_ID, CUSTOM_INT, Value((int64_t)42)); + TEST_ASSERT_TRUE(p.discard(CUSTOM_NS_ID)); + + TEST_ASSERT_EQUAL_INT64(5, p.field(CUSTOM_NS_ID, CUSTOM_INT).as_int()); + TEST_ASSERT_EQUAL(Type::None, + (int)p.field(CUSTOM_NS_ID, CUSTOM_INT, Manager::Source::Draft).type()); + TEST_ASSERT_EQUAL(0, g_live_int_setter_count); +} + +void test_reload_after_reboot(void) { + // Commit a change, simulate restart, expect value to survive. + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + + p.field(CUSTOM_NS_ID, CUSTOM_INT, Value((int64_t)77)); + p.field(CUSTOM_NS_ID, CUSTOM_STR, Value("hello")); + TEST_ASSERT_TRUE(p.commit()); + + // Reset the counter so we can isolate the reload's behaviour from + // the commit that just ran. + reset_setter_counters(); + + // Simulate reboot: tear down and re-init using the same storage root. + fresh_provisioning(g_test_root); + auto& p2 = Manager::instance(); + + TEST_ASSERT_EQUAL_INT64(77, p2.field(CUSTOM_NS_ID, CUSTOM_INT).as_int()); + TEST_ASSERT_EQUAL_STRING("hello", + p2.field(CUSTOM_NS_ID, CUSTOM_STR).as_string().c_str()); + // needs_reboot is reset on a fresh boot. + TEST_ASSERT_FALSE(p2.needs_reboot()); + // Boot-time apply DOES push the loaded working value into the runtime + // static via the field's setter — without that step, persistence would + // be decorative (working map updated but runtime statics unchanged). + // Expect exactly one setter call with the value loaded from disk. + TEST_ASSERT_EQUAL(1, g_live_int_setter_count); + TEST_ASSERT_EQUAL_INT64(77, g_live_int_setter_value); +} + +void test_constraint_violation_rejected(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + + // Out-of-range for [0, 100]. + TEST_ASSERT_FALSE(p.field(CUSTOM_NS_ID, CUSTOM_INT, Value((int64_t)500))); + // Working unchanged. + TEST_ASSERT_EQUAL_INT64(5, p.field(CUSTOM_NS_ID, CUSTOM_INT).as_int()); + // No draft entry created. + TEST_ASSERT_EQUAL(Type::None, + (int)p.field(CUSTOM_NS_ID, CUSTOM_INT, Manager::Source::Draft).type()); + + // Wrong type also rejected. + TEST_ASSERT_FALSE(p.field(CUSTOM_NS_ID, CUSTOM_INT, Value("oops"))); +} + +void test_live_vs_reboot_apply(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + + TEST_ASSERT_FALSE(p.draft_has_reboot()); + TEST_ASSERT_FALSE(p.needs_reboot()); + + // Live field — draft_has_reboot stays false. + p.field(CUSTOM_NS_ID, CUSTOM_INT, Value((int64_t)42)); + TEST_ASSERT_FALSE(p.draft_has_reboot()); + + // Reboot-required field — draft_has_reboot flips on. + p.field(CUSTOM_NS_ID, CUSTOM_REBOOT_INT, Value((int64_t)868000000)); + TEST_ASSERT_TRUE(p.draft_has_reboot()); + + TEST_ASSERT_TRUE(p.commit()); + // Live setter was called; reboot setter was NOT. + TEST_ASSERT_EQUAL(1, g_live_int_setter_count); + TEST_ASSERT_EQUAL(0, g_reboot_int_setter_count); + // needs_reboot reflects the committed REBOOT_REQUIRED field. + TEST_ASSERT_TRUE(p.needs_reboot()); + // Draft has been cleared, so draft_has_reboot is now false. + TEST_ASSERT_FALSE(p.draft_has_reboot()); +} + +void test_reboot_callback_fires_once(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + + int callback_count = 0; + p.on_reboot_requested([&callback_count]() { ++callback_count; }); + + p.field(CUSTOM_NS_ID, CUSTOM_REBOOT_INT, Value((int64_t)868000000)); + TEST_ASSERT_TRUE(p.commit()); + TEST_ASSERT_EQUAL(1, callback_count); + + // A second commit of another reboot field should NOT fire again + // (needs_reboot is sticky until reboot or factory_reset). + p.field(CUSTOM_NS_ID, CUSTOM_REBOOT_INT, Value((int64_t)869000000)); + TEST_ASSERT_TRUE(p.commit()); + TEST_ASSERT_EQUAL(1, callback_count); +} + +void test_boot_apply_fires_reboot_required_setter(void) { + // Commit a REBOOT_REQUIRED field. The setter must NOT fire on commit + // (existing post-boot semantic) but MUST fire once on the next boot + // when apply_loaded_to_runtime() walks the registry. + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + + p.field(CUSTOM_NS_ID, CUSTOM_REBOOT_INT, Value((int64_t)868000000)); + TEST_ASSERT_TRUE(p.commit()); + // REBOOT_REQUIRED setter is NOT called by commit(). + TEST_ASSERT_EQUAL(0, g_reboot_int_setter_count); + TEST_ASSERT_TRUE(p.needs_reboot()); + + // Simulate reboot. Reset counters so the boot apply is isolated. + reset_setter_counters(); + fresh_provisioning(g_test_root); + auto& p2 = Manager::instance(); + + // Boot apply fires the REBOOT_REQUIRED setter exactly once. + TEST_ASSERT_EQUAL(1, g_reboot_int_setter_count); + // And needs_reboot is cleared on the fresh boot — the reboot already + // happened, the deferred-apply just executed. + TEST_ASSERT_FALSE(p2.needs_reboot()); + // Working value still reflects what was on disk. + TEST_ASSERT_EQUAL_INT64(868000000, p2.field(CUSTOM_NS_ID, CUSTOM_REBOOT_INT).as_int()); +} + +void test_unknown_field_ignored_on_load(void) { + // Hand-craft a MsgPack file with an extra unknown field and verify it + // loads cleanly with the unknown id silently dropped. + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + + p.field(CUSTOM_NS_ID, CUSTOM_INT, Value((int64_t)33)); + TEST_ASSERT_TRUE(p.commit()); + + // Inject an unknown id into the file by rewriting it manually. + std::string path = g_test_root + "/custom.msgpack"; + MsgPack::Packer packer; + packer.serialize(MsgPack::map_size_t(2)); + packer.serialize((uint16_t)CUSTOM_INT); + packer.serialize((int64_t)77); + packer.serialize((uint16_t)9999); // unknown field id + packer.serialize((int64_t)12345); + Bytes raw(packer.data(), packer.size()); + Utilities::OS::write_file(path.c_str(), raw); + + // Reload. + fresh_provisioning(g_test_root); + auto& p2 = Manager::instance(); + + TEST_ASSERT_EQUAL_INT64(77, p2.field(CUSTOM_NS_ID, CUSTOM_INT).as_int()); +} + +void test_atomic_write_resilience(void) { + // Write a normal file, then drop a stray .tmp and ensure load_all + // removes it and still loads the real file. + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + p.field(CUSTOM_NS_ID, CUSTOM_INT, Value((int64_t)21)); + TEST_ASSERT_TRUE(p.commit()); + + // Plant a garbage .tmp (mimicking an interrupted save). + std::string tmp_path = g_test_root + "/custom.msgpack.tmp"; + Bytes garbage("not valid msgpack"); + Utilities::OS::write_file(tmp_path.c_str(), garbage); + TEST_ASSERT_TRUE(Utilities::OS::file_exists(tmp_path.c_str())); + + fresh_provisioning(g_test_root); + auto& p2 = Manager::instance(); + + TEST_ASSERT_EQUAL_INT64(21, p2.field(CUSTOM_NS_ID, CUSTOM_INT).as_int()); + // .tmp was cleaned up on load. + TEST_ASSERT_FALSE(Utilities::OS::file_exists(tmp_path.c_str())); +} + +void test_factory_reset(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + + p.field(CUSTOM_NS_ID, CUSTOM_INT, Value((int64_t)42)); + p.commit(); + TEST_ASSERT_EQUAL_INT64(42, p.field(CUSTOM_NS_ID, CUSTOM_INT).as_int()); + + TEST_ASSERT_TRUE(p.factory_reset()); + TEST_ASSERT_EQUAL_INT64(5, p.field(CUSTOM_NS_ID, CUSTOM_INT).as_int()); + TEST_ASSERT_FALSE(Utilities::OS::file_exists((g_test_root + "/custom.msgpack").c_str())); + + // Survives reboot. + fresh_provisioning(g_test_root); + TEST_ASSERT_EQUAL_INT64(5, Manager::instance().field(CUSTOM_NS_ID, CUSTOM_INT).as_int()); +} + +void test_by_name_accessors(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + + TEST_ASSERT_TRUE(p.field("custom", "level", Value((int64_t)17))); + TEST_ASSERT_EQUAL_INT64(17, p.field("custom", "level", Manager::Source::Draft).as_int()); + + TEST_ASSERT_TRUE(p.commit("custom")); + TEST_ASSERT_EQUAL_INT64(17, p.field("custom", "level").as_int()); + + // Unknown ns/field returns a None value rather than throwing. + Value missing = p.field("nope", "nada"); + TEST_ASSERT_EQUAL(Type::None, (int)missing.type()); +} + +void test_duplicate_field_id_rejected(void) { + // Register a fresh namespace with a duplicate field id; second add + // must fail (Namespace::add_field returns false). + Manager::instance().end(); + auto b = Manager::instance() + .namespace_("dup", 9200) + .field_int("a", 1, FF_LIVE_APPLY, 0, 0, 10) + .field_int("b", 1, FF_LIVE_APPLY, 0, 0, 10); + // Pull the underlying namespace to inspect. + Manager::instance().begin(g_test_root.c_str()); + const Namespace* ns = Manager::instance().registry().find((uint16_t)9200); + TEST_ASSERT_NOT_NULL(ns); + TEST_ASSERT_EQUAL(1u, ns->fields().size()); // duplicate dropped +} + +// --------------------------------------------------------------------------- +// Wire round-trip tests — exercise handle_message() +// --------------------------------------------------------------------------- + +// Build a wire envelope [op, seq, payload] where payload-packing is +// delegated to a lambda. Returns the encoded bytes. +template +static Bytes make_request(uint8_t op, uint64_t seq, F&& pack_payload) { + MsgPack::Packer p; + p.serialize(MsgPack::arr_size_t(3)); + p.serialize(op); + p.serialize(seq); + pack_payload(p); + return Bytes(p.data(), p.size()); +} + +void test_wire_get_info(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + + Bytes req = make_request((uint8_t)Op::GetInfo, 42, [](MsgPack::Packer& pk) { + MsgPack::object::nil_t n; + pk.serialize(n); + }); + Bytes resp = p.handle_message(req); + TEST_ASSERT_GREATER_THAN(0, resp.size()); + + MsgPack::Unpacker u; + u.feed(resp.data(), resp.size()); + TEST_ASSERT_TRUE(u.isArray()); + const size_t n = u.unpackArraySize(); + TEST_ASSERT_EQUAL(3, n); + + uint8_t op = 0; u.deserialize(op); + uint64_t seq = 0; u.deserialize(seq); + TEST_ASSERT_EQUAL((uint8_t)Op::GetInfo, op); + TEST_ASSERT_EQUAL(42, seq); + + // Payload should be a map with at least the schema version. + TEST_ASSERT_TRUE(u.isMap()); +} + +void test_wire_set_state_then_commit(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + + // SetState: { CUSTOM_NS_ID: { CUSTOM_INT: 88 } } + Bytes req = make_request((uint8_t)Op::SetState, 1, [&](MsgPack::Packer& pk) { + pk.serialize(MsgPack::map_size_t(1)); + pk.serialize((uint16_t)CUSTOM_NS_ID); + pk.serialize(MsgPack::map_size_t(1)); + pk.serialize((uint16_t)CUSTOM_INT); + pk.serialize((int64_t)88); + }); + Bytes resp = p.handle_message(req); + TEST_ASSERT_GREATER_THAN(0, resp.size()); + // Working still default, draft has 88. + TEST_ASSERT_EQUAL_INT64(5, p.field(CUSTOM_NS_ID, CUSTOM_INT).as_int()); + TEST_ASSERT_EQUAL_INT64(88, + p.field(CUSTOM_NS_ID, CUSTOM_INT, Manager::Source::Draft).as_int()); + + // Commit + Bytes commit_req = make_request((uint8_t)Op::Commit, 2, [](MsgPack::Packer& pk) { + MsgPack::object::nil_t n; + pk.serialize(n); + }); + Bytes commit_resp = p.handle_message(commit_req); + TEST_ASSERT_GREATER_THAN(0, commit_resp.size()); + TEST_ASSERT_EQUAL_INT64(88, p.field(CUSTOM_NS_ID, CUSTOM_INT).as_int()); + TEST_ASSERT_EQUAL(1, g_live_int_setter_count); +} + +void test_wire_set_state_constraint_error(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + + Bytes req = make_request((uint8_t)Op::SetState, 1, [&](MsgPack::Packer& pk) { + pk.serialize(MsgPack::map_size_t(1)); + pk.serialize((uint16_t)CUSTOM_NS_ID); + pk.serialize(MsgPack::map_size_t(1)); + pk.serialize((uint16_t)CUSTOM_INT); + pk.serialize((int64_t)999); // out of range + }); + Bytes resp = p.handle_message(req); + + MsgPack::Unpacker u; + u.feed(resp.data(), resp.size()); + u.unpackArraySize(); + uint8_t op = 0; u.deserialize(op); + uint64_t seq = 0; u.deserialize(seq); + TEST_ASSERT_EQUAL((uint8_t)Op::SetState, op); + + // Payload should contain an errors array. + TEST_ASSERT_TRUE(u.isMap()); + const size_t n = u.unpackMapSize(); + bool found_errors = false; + for (size_t i = 0; i < n; ++i) { + uint16_t key = 0; + int64_t k = 0; u.deserialize(k); + key = (uint16_t)k; + if (key == Key::FieldErrors && u.isArray()) { + const size_t en = u.unpackArraySize(); + TEST_ASSERT_GREATER_THAN(0, en); + found_errors = true; + // Consume remaining elements + for (size_t j = 0; j < en; ++j) { + u.unpackArraySize(); + int64_t a, b, c; u.deserialize(a); u.deserialize(b); u.deserialize(c); + } + } + else if (u.isUInt() || u.isInt()) { int64_t v; u.deserialize(v); } + else if (u.isBool()) { bool b; u.deserialize(b); } + else { u.unpackArraySize(); } + } + TEST_ASSERT_TRUE(found_errors); + // Live setter NOT invoked. + TEST_ASSERT_EQUAL(0, g_live_int_setter_count); +} + +void test_wire_get_capabilities(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + + Bytes req = make_request((uint8_t)Op::GetCapabilities, 1, [](MsgPack::Packer& pk) { + MsgPack::object::nil_t n; + pk.serialize(n); + }); + Bytes resp = p.handle_message(req); + + MsgPack::Unpacker u; + u.feed(resp.data(), resp.size()); + u.unpackArraySize(); + uint8_t op = 0; u.deserialize(op); + uint64_t seq = 0; u.deserialize(seq); + TEST_ASSERT_EQUAL((uint8_t)Op::GetCapabilities, op); + TEST_ASSERT_TRUE(u.isArray()); + const size_t cap = u.unpackArraySize(); + TEST_ASSERT_GREATER_OR_EQUAL(3, cap); // reticulum + transport + identity + custom +} + +void test_wire_factory_reset(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + p.field(CUSTOM_NS_ID, CUSTOM_INT, Value((int64_t)42)); + p.commit(); + + Bytes req = make_request((uint8_t)Op::FactoryReset, 1, [](MsgPack::Packer& pk) { + MsgPack::object::nil_t n; + pk.serialize(n); + }); + Bytes resp = p.handle_message(req); + TEST_ASSERT_GREATER_THAN(0, resp.size()); + TEST_ASSERT_EQUAL_INT64(5, p.field(CUSTOM_NS_ID, CUSTOM_INT).as_int()); +} + +void test_wire_unknown_op_error(void) { + fresh_provisioning(g_test_root); + auto& p = Manager::instance(); + + Bytes req = make_request(250, 7, [](MsgPack::Packer& pk) { + MsgPack::object::nil_t n; + pk.serialize(n); + }); + Bytes resp = p.handle_message(req); + + MsgPack::Unpacker u; + u.feed(resp.data(), resp.size()); + u.unpackArraySize(); + uint8_t op = 0; u.deserialize(op); + uint64_t seq = 0; u.deserialize(seq); + TEST_ASSERT_EQUAL((uint8_t)Op::Error, op); + TEST_ASSERT_EQUAL(7, seq); +} + +// --------------------------------------------------------------------------- +// Runner +// --------------------------------------------------------------------------- + +int runUnityTests(void) { + UNITY_BEGIN(); + + // Suite setup: register the filesystem once. + microStore::FileSystem filesystem{microStore::Adapters::UniversalFileSystem()}; + filesystem.init(); + Utilities::OS::register_filesystem(filesystem); + + RUN_TEST(test_register_and_lookup); + RUN_TEST(test_default_values); + RUN_TEST(test_set_draft_independent_of_working); + RUN_TEST(test_commit_promotes_and_invokes_live_setter); + RUN_TEST(test_discard_clears_draft); + RUN_TEST(test_reload_after_reboot); + RUN_TEST(test_constraint_violation_rejected); + RUN_TEST(test_live_vs_reboot_apply); + RUN_TEST(test_reboot_callback_fires_once); + RUN_TEST(test_boot_apply_fires_reboot_required_setter); + RUN_TEST(test_unknown_field_ignored_on_load); + RUN_TEST(test_atomic_write_resilience); + RUN_TEST(test_factory_reset); + RUN_TEST(test_by_name_accessors); + RUN_TEST(test_duplicate_field_id_rejected); + RUN_TEST(test_wire_get_info); + RUN_TEST(test_wire_set_state_then_commit); + RUN_TEST(test_wire_set_state_constraint_error); + RUN_TEST(test_wire_get_capabilities); + RUN_TEST(test_wire_factory_reset); + RUN_TEST(test_wire_unknown_op_error); + + Utilities::OS::deregister_filesystem(); + return UNITY_END(); +} + +int main(void) { + return runUnityTests(); +} + +#ifdef ARDUINO +void setup() { delay(2000); runUnityTests(); } +void loop() {} +#endif