From 4bc09d39d7119f7fdf6086cb8fc0aba93130e9b0 Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Thu, 4 Jun 2026 02:52:50 +0200 Subject: [PATCH 1/4] Bump OAN + add OAN_UID_AUTOCONF top-level option Phase 1 of UID autoconfiguration lands in OpenAudioNetwork behind the new OAN_UID_AUTOCONF flag (default OFF). Mirror the option here so the parent build can propagate it; bump the submodule pointer to pick up the new structs, algorithm, and tests. See Docs/proposals/uid-autoconfiguration.md and -impl.md. --- CMakeLists.txt | 8 ++++++++ OpenAudioNetwork | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index dc56792..d1db6c3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,6 +16,14 @@ if(OAN_HOST_BACKENDS) add_compile_definitions(OAN_HOST_BACKENDS) endif() +# Phase 1 of UID autoconfiguration: structs and algorithm compile in +# unconditionally; the algorithm only runs when this flag is set. +# Default OFF — phase 2 will flip it on for Linux + host-backend builds. +option(OAN_UID_AUTOCONF "Enable UID autoconfiguration" OFF) +if(OAN_UID_AUTOCONF) + add_compile_definitions(OAN_UID_AUTOCONF) +endif() + if(APPLE) # macOS root volume is read-only (SIP), so the Linux target's /core_plugins # path can't be created. Use a per-user dir under $HOME instead — keeps diff --git a/OpenAudioNetwork b/OpenAudioNetwork index bafa755..65c86b7 160000 --- a/OpenAudioNetwork +++ b/OpenAudioNetwork @@ -1 +1 @@ -Subproject commit bafa7554b8070c22cc64820c87293c4aa1a00b69 +Subproject commit 65c86b7a6be7d0e5de7d63f8dd1156048393ef52 From 95bf652ab22de25d7d708e0d8102743147a1e0ed Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Thu, 4 Jun 2026 03:33:33 +0200 Subject: [PATCH 2/4] Wire UID autoconfiguration into engine, UI, and io_sim (phase 2) Engine, coreui, and io_sim now derive their OAN UID via the autoconfig algorithm (MAC-hash + collision probe) on first boot and persist it for subsequent boots. The flag OAN_UID_AUTOCONF defaults ON for Linux and host-backend dev builds; firmwares stay OFF and wire it themselves in phase 3. - engine: NetMan owns the autoconfig step; main.cpp constructs an EnvOverrideUidStore over a FileUidStore at /var/lib/oals/engine-.uid (Linux) or ~/.local/state/oals/engine-.uid (host), and defers AudioRouter construction until NetMan has committed a UID. Removes the hardcoded uid=100. - coreui: ShowManager wraps surface.json with a QJsonFieldUidStore (QSaveFile atomic write of network.persisted_uid). NetworkConfig splits hint_uid (static-range pin from network.uid) from persisted_uid and the committed uid. Static-range hints bypass the probe phase. - io_sim: inline JsonFieldUidStore over the config JSON (atomic temp+rename), removes the long-standing hardcoded uid=1, fixes ClockSlave to use the committed UID, moves launch_mapping_process after autoconfig. - All three accept --renumber to clear the persisted UID and re-derive. --- CMakeLists.txt | 15 ++- coreui/core/NetworkConfig.h | 11 +- coreui/core/ShowManager.cpp | 139 +++++++++++++++++++++++- coreui/core/ShowManager.h | 5 + coreui/main.cpp | 10 ++ coreui/surface_config/surface.json | 20 ++-- engine/NetMan.cpp | 23 ++++ engine/NetMan.h | 14 +++ engine/main.cpp | 62 ++++++++++- io_sim/io_sim.example.json | 2 +- io_sim/main.cpp | 163 +++++++++++++++++++++++++++-- 11 files changed, 429 insertions(+), 35 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d1db6c3..dab3be4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,13 +16,20 @@ if(OAN_HOST_BACKENDS) add_compile_definitions(OAN_HOST_BACKENDS) endif() -# Phase 1 of UID autoconfiguration: structs and algorithm compile in -# unconditionally; the algorithm only runs when this flag is set. -# Default OFF — phase 2 will flip it on for Linux + host-backend builds. -option(OAN_UID_AUTOCONF "Enable UID autoconfiguration" OFF) +# UID autoconfiguration: on by default for Linux production + host-backend +# dev. Off elsewhere (e.g. firmware-style toolchain where the build picks up +# this top-level CMake — unusual, but the firmwares vendor OAN directly and +# do their own flag wiring in phase 3). +if(CMAKE_SYSTEM_NAME STREQUAL "Linux" OR OAN_HOST_BACKENDS) + set(_OAN_UID_AUTOCONF_DEFAULT ON) +else() + set(_OAN_UID_AUTOCONF_DEFAULT OFF) +endif() +option(OAN_UID_AUTOCONF "Enable UID autoconfiguration" ${_OAN_UID_AUTOCONF_DEFAULT}) if(OAN_UID_AUTOCONF) add_compile_definitions(OAN_UID_AUTOCONF) endif() +message(STATUS "OAN_UID_AUTOCONF = ${OAN_UID_AUTOCONF}") if(APPLE) # macOS root volume is read-only (SIP), so the Linux target's /core_plugins diff --git a/coreui/core/NetworkConfig.h b/coreui/core/NetworkConfig.h index d5f9347..29caee5 100644 --- a/coreui/core/NetworkConfig.h +++ b/coreui/core/NetworkConfig.h @@ -13,7 +13,16 @@ struct NetworkConfig { std::string eth_interface; - uint16_t uid; + // hint_uid: 0 = no hint, static-range value = pin (autoconfig skipped), + // dynamic-range value = ignored with a warning (per design §2.5). + uint16_t hint_uid = 0; + // persisted_uid: the autoconfigurator's last-committed value, fed back + // into the configurator as a "try this first" hint. Optional. + uint16_t persisted_uid = 0; + + // After init_console runs, this is the committed UID (autoconfigured + // or static-pinned). + uint16_t uid = 0; QJsonObject serialize(); }; diff --git a/coreui/core/ShowManager.cpp b/coreui/core/ShowManager.cpp index 39b586d..a9772e0 100644 --- a/coreui/core/ShowManager.cpp +++ b/coreui/core/ShowManager.cpp @@ -5,8 +5,100 @@ #include "ShowManager.h" +#ifdef OAN_UID_AUTOCONF +#include "OpenAudioNetwork/common/UidStore.h" + +#include +#include +#endif + #include +#ifdef OAN_UID_AUTOCONF +namespace { + +// Persist the autoconfigured UID into a top-level field of an existing +// QJson document. Atomic via QSaveFile (write-tmp-then-rename). +class QJsonFieldUidStore : public IUidStore { +public: + QJsonFieldUidStore(QString path, QString field) + : m_path(std::move(path)), m_field(std::move(field)) {} + + std::optional load() override { + QFile f(m_path); + if (!f.open(QIODevice::ReadOnly)) return std::nullopt; + QJsonParseError err{}; + auto doc = QJsonDocument::fromJson(f.readAll(), &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) { + std::cerr << "QJsonFieldUidStore: parse '" << m_path.toStdString() + << "' failed: " << err.errorString().toStdString() << std::endl; + return std::nullopt; + } + auto root = doc.object(); + auto net = root.value("network").toObject(); + auto v = net.value(m_field); + if (!v.isDouble()) return std::nullopt; + int i = v.toInt(-1); + if (i < 0 || i > 0xFFFF) return std::nullopt; + return static_cast(i); + } + + void save(uint16_t uid) override { + QJsonObject root; + { + QFile f(m_path); + if (f.open(QIODevice::ReadOnly)) { + auto doc = QJsonDocument::fromJson(f.readAll()); + if (doc.isObject()) root = doc.object(); + } + } + QJsonObject net = root.value("network").toObject(); + net.insert(m_field, static_cast(uid)); + root.insert("network", net); + + QSaveFile out(m_path); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + std::cerr << "QJsonFieldUidStore: open '" << m_path.toStdString() + << "' for write failed." << std::endl; + return; + } + out.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + if (!out.commit()) { + std::cerr << "QJsonFieldUidStore: commit '" << m_path.toStdString() + << "' failed." << std::endl; + } + } + + void clear() override { + QFile f(m_path); + if (!f.open(QIODevice::ReadOnly)) return; + auto doc = QJsonDocument::fromJson(f.readAll()); + f.close(); + if (!doc.isObject()) return; + auto root = doc.object(); + auto net = root.value("network").toObject(); + if (!net.contains(m_field)) return; + net.remove(m_field); + root.insert("network", net); + + QSaveFile out(m_path); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) return; + out.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + out.commit(); + } + +private: + QString m_path; + QString m_field; +}; + +bool is_static_range(uint16_t uid) { + return uid >= 0xF000 && uid <= 0xFFFE; +} + +} // namespace +#endif + ShowManager::ShowManager() : QObject(nullptr) { m_netconfig = NetworkConfig{}; } @@ -22,7 +114,15 @@ bool ShowManager::init_console(SignalWindow* sw) { infos.dev_type = DeviceType::CONTROL_SURFACE; infos.iface = m_netconfig.eth_interface; infos.sample_rate = SamplingRate::SAMPLING_96K; - infos.uid = m_netconfig.uid; +#ifdef OAN_UID_AUTOCONF + // Hint = static-range pin only; dynamic-range hints are ignored + // by the configurator (per design §2.5). + infos.uid = is_static_range(m_netconfig.hint_uid) ? m_netconfig.hint_uid : 0; +#else + // Pre-autoconfig path: honour whatever was in the config, fall back + // to the historical 200 if nothing was set. + infos.uid = m_netconfig.hint_uid != 0 ? m_netconfig.hint_uid : 200; +#endif infos.topo.phy_in_count = 0; infos.topo.phy_out_count = 0; infos.topo.pipes_count = 0; @@ -34,6 +134,33 @@ bool ShowManager::init_console(SignalWindow* sw) { return false; } +#ifdef OAN_UID_AUTOCONF + if (!is_static_range(m_netconfig.hint_uid)) { + // No static pin — run autoconfig, persist to surface.json. + auto backing = std::make_unique( + QStringLiteral("surface_config/surface.json"), + QStringLiteral("persisted_uid")); + if (m_renumber) { + std::cout << "--renumber: clearing persisted UID in surface.json" << std::endl; + backing->clear(); + } + EnvOverrideUidStore store{"OAN_PERSISTED_UID", std::move(backing)}; + uint16_t committed = m_nmapper->autoconfigure_uid(store); + if (committed == 0) { + std::cerr << "ShowManager: UID autoconfiguration failed." << std::endl; + return false; + } + m_netconfig.uid = committed; + } else { + m_netconfig.uid = m_netconfig.hint_uid; + std::cout << "ShowManager: static-range UID 0x" << std::hex + << m_netconfig.hint_uid << std::dec + << " pinned; autoconfig skipped." << std::endl; + } +#else + m_netconfig.uid = infos.uid; +#endif + std::cout << "Starting netmapper and router processes on interface " << infos.iface << std::endl; m_nmapper->set_peer_change_callback([this](PeerInfos& infos, bool peer_state) { @@ -155,13 +282,14 @@ void ShowManager::load_console_config() { if (!config_file.open(QIODeviceBase::ReadOnly)) { std::cerr << "Failed to open surface.json config file" << std::endl; - std::cerr << "Using default config (iface = lo, uid = 200)" << std::endl; + std::cerr << "Using default config (iface = lo, no UID hint)" << std::endl; NetworkConfig netcfg{}; netcfg.eth_interface = "lo"; - netcfg.uid = 200; - + netcfg.hint_uid = 0; + netcfg.persisted_uid = 0; m_netconfig = std::move(netcfg); + return; } auto doc = QJsonDocument::fromJson(config_file.readAll()); @@ -169,7 +297,8 @@ void ShowManager::load_console_config() { NetworkConfig netcfg{}; netcfg.eth_interface = net_root["eth_interface"].toString("lo").toStdString(); - netcfg.uid = net_root["uid"].toInt(200); + netcfg.hint_uid = static_cast(net_root["uid"].toInt(0)); + netcfg.persisted_uid = static_cast(net_root["persisted_uid"].toInt(0)); m_netconfig = std::move(netcfg); } diff --git a/coreui/core/ShowManager.h b/coreui/core/ShowManager.h index e1e643e..9139c3f 100644 --- a/coreui/core/ShowManager.h +++ b/coreui/core/ShowManager.h @@ -44,6 +44,10 @@ class ShowManager : public QObject { bool init_console(SignalWindow* sw); + // Set before init_console: if true, the autoconfigurator clears any + // persisted UID before deriving a fresh one. + void set_renumber(bool r) { m_renumber = r; } + void add_pipe(PipeDesc *pipe_desc, QString pipe_name, uint8_t channel, uint16_t host, uint16_t pid, bool unsynced = false); void update_page(SignalWindow* swin); @@ -74,6 +78,7 @@ class ShowManager : public QObject { std::shared_ptr m_nmapper; NetworkConfig m_netconfig; + bool m_renumber = false; DSPManager* m_dsp_manager; std::shared_ptr m_plugin_loader; diff --git a/coreui/main.cpp b/coreui/main.cpp index b47aeec..842bb0b 100644 --- a/coreui/main.cpp +++ b/coreui/main.cpp @@ -7,6 +7,8 @@ #include #include +#include + #include "ui/SignalWindow.h" #include "ui/SetupWindow.h" @@ -19,9 +21,17 @@ #endif int main(int argc, char* argv[]) { + bool renumber = false; + for (int i = 1; i < argc; ++i) { + if (std::string(argv[i]) == "--renumber") { + renumber = true; + } + } + QApplication qapp {argc, argv}; auto* sm = new ShowManager{}; + sm->set_renumber(renumber); // Software initialization // Load stored console config diff --git a/coreui/surface_config/surface.json b/coreui/surface_config/surface.json index bb63556..60810d9 100644 --- a/coreui/surface_config/surface.json +++ b/coreui/surface_config/surface.json @@ -1,10 +1,12 @@ { - "network": { - "eth_interface": "sim:default", - "uid": 200 - }, - - "plugins": { - "search_paths": ["~/osst/plugins/"] - } -} \ No newline at end of file + "network": { + "eth_interface": "sim:default", + "persisted_uid": 21190, + "uid": 200 + }, + "plugins": { + "search_paths": [ + "~/osst/plugins/" + ] + } +} diff --git a/engine/NetMan.cpp b/engine/NetMan.cpp index 354114e..4922d9d 100644 --- a/engine/NetMan.cpp +++ b/engine/NetMan.cpp @@ -15,12 +15,20 @@ NetMan::~NetMan() { } +#ifdef OAN_UID_AUTOCONF +bool NetMan::init_netman(const std::string& iface, IUidStore* uid_store) { +#else bool NetMan::init_netman(const std::string& iface) { +#endif m_pconf = PeerConf{}; m_pconf.dev_type = DeviceType::AUDIO_DSP; m_pconf.sample_rate = SamplingRate::SAMPLING_96K; m_pconf.topo = NodeTopology{0, 0, 64, 0xFFFFFFFFFFFFFFFF}; +#ifdef OAN_UID_AUTOCONF + m_pconf.uid = 0; // 0 = "no hint", let the configurator pick. +#else m_pconf.uid = 100; +#endif m_pconf.iface = iface; m_pconf.ck_type = CKTYPE_MASTER; @@ -33,6 +41,21 @@ bool NetMan::init_netman(const std::string& iface) { return false; } +#ifdef OAN_UID_AUTOCONF + if (uid_store) { + uint16_t committed = m_nmapper->autoconfigure_uid(*uid_store); + if (committed == 0) { + std::cerr << LOG_PREFIX << "UID autoconfiguration failed." << std::endl; + return false; + } + m_pconf.uid = committed; + } else { + // No store passed: autoconfig flag is on but caller opted out. Use + // a sentinel that downstream sockets can still construct against. + m_pconf.uid = m_nmapper->committed_uid(); + } +#endif + m_dsp_control = std::make_unique(m_pconf.uid, m_nmapper); if (!m_dsp_control->init_socket(m_pconf.iface, EthProtocol::ETH_PROTO_OANCONTROL)) { std::cerr << LOG_PREFIX << "Failed to init DSP Control socket." << std::endl; diff --git a/engine/NetMan.h b/engine/NetMan.h index 9df9596..6643516 100644 --- a/engine/NetMan.h +++ b/engine/NetMan.h @@ -15,6 +15,10 @@ #include "piping/AudioPlumber.h" #include "log.h" +#ifdef OAN_UID_AUTOCONF +#include "OpenAudioNetwork/common/UidStore.h" +#endif + #include @@ -23,7 +27,17 @@ class NetMan { NetMan(AudioPlumber* plumber); ~NetMan(); +#ifdef OAN_UID_AUTOCONF + // Init the network manager and run UID autoconfiguration against the + // given store. Store may be null to skip autoconfig (kept for tests + // and the flag-off code path that doesn't link this overload). + bool init_netman(const std::string& iface, IUidStore* uid_store); +#else bool init_netman(const std::string& iface); +#endif + + uint16_t committed_uid() const { return m_pconf.uid; } + void update_netman(); void start_mapping(); diff --git a/engine/main.cpp b/engine/main.cpp index 8493f44..e535789 100644 --- a/engine/main.cpp +++ b/engine/main.cpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #ifdef OAN_HOST_BACKENDS #include // pause() @@ -23,8 +25,37 @@ #include "OpenAudioNetwork/common/AudioRouter.h" #include "OpenAudioNetwork/common/ClockMaster.h" +#ifdef OAN_UID_AUTOCONF +#include "OpenAudioNetwork/common/UidStore.h" +#endif + #include "OpenAudioNetwork/netutils/platform/rt.h" +#ifdef OAN_UID_AUTOCONF +namespace { + +std::string sanitise_iface(const std::string& iface) { + std::string out; + out.reserve(iface.size()); + for (char c : iface) { + out.push_back((std::isalnum(static_cast(c)) || c == '-' || c == '_') ? c : '_'); + } + return out; +} + +std::string engine_uid_path(const std::string& iface) { +#ifdef OAN_HOST_BACKENDS + const char* home = std::getenv("HOME"); + std::string base = (home && *home) ? std::string(home) + "/.local/state/oals" : "/tmp/oals"; +#else + std::string base = "/var/lib/oals"; +#endif + return base + "/engine-" + sanitise_iface(iface) + ".uid"; +} + +} // namespace +#endif + static void print_usage() { std::cout << "OALSEngine — Open Audio Live System DSP engine\n" @@ -40,7 +71,9 @@ static void print_usage() { " raw: (Mac BPF — not yet implemented)\n" " Defaults to \"lo\" when omitted.\n" "\n" - " --help Show this message.\n" + " --renumber Clear persisted UID before boot, then autoconfigure\n" + " from scratch. Useful after a hardware swap.\n" + " --help Show this message.\n" "\n" "The engine runs four detached RT threads (audio recv, control recv,\n" "pipe updater, clock syncer) plus a main thread that parks. It must\n" @@ -50,12 +83,17 @@ static void print_usage() { int main(int argc, char* argv[]) { std::string eth_interface = "lo"; - if (argc > 1) { - std::string arg = argv[1]; + bool renumber = false; + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; if (arg == "--help" || arg == "-h") { print_usage(); return 0; } + if (arg == "--renumber") { + renumber = true; + continue; + } eth_interface = std::move(arg); } @@ -63,11 +101,27 @@ int main(int argc, char* argv[]) { AudioEngine audio_engine{}; NetMan nman{&plumber}; - AudioRouter router{100}; +#ifdef OAN_UID_AUTOCONF + auto file_store = std::make_unique(engine_uid_path(eth_interface)); + if (renumber) { + std::cout << LOG_PREFIX << "--renumber: clearing persisted UID at " + << engine_uid_path(eth_interface) << std::endl; + file_store->clear(); + } + EnvOverrideUidStore uid_store{"OAN_PERSISTED_UID", std::move(file_store)}; + if (!nman.init_netman(eth_interface, &uid_store)) { + std::cerr << LOG_PREFIX << "Failed to initialize network manager." << std::endl; + return -1; + } +#else + (void)renumber; if (!nman.init_netman(eth_interface)) { std::cerr << LOG_PREFIX << "Failed to initialize network manager." << std::endl; } +#endif + + AudioRouter router{nman.committed_uid()}; if (!router.init_router(eth_interface, nman.get_net_mapper())) { std::cerr << LOG_PREFIX << "Failed to initialize audio router." << std::endl; diff --git a/io_sim/io_sim.example.json b/io_sim/io_sim.example.json index 8a1077a..d8915a9 100644 --- a/io_sim/io_sim.example.json +++ b/io_sim/io_sim.example.json @@ -1,5 +1,5 @@ { - "uid": 1, + "_comment_uid": "UID is autoconfigured at first boot and written back as 'persisted_uid'. To pin a static UID, set 'uid' to a value in 0xF000-0xFFFE. To re-derive, run with --renumber.", "tracks": [ { "channel": 0, "tone": { "freq": 440.0, "gain": 0.3 } }, { "channel": 1, "tone": { "freq": 880.0, "gain": 0.3 } }, diff --git a/io_sim/main.cpp b/io_sim/main.cpp index 3f06f92..bbe0932 100644 --- a/io_sim/main.cpp +++ b/io_sim/main.cpp @@ -12,6 +12,8 @@ #include #include #include +#include +#include #include #include @@ -20,6 +22,10 @@ #include #include +#ifdef OAN_UID_AUTOCONF +#include +#endif + #include static constexpr int IO_SIM_SAMPLE_RATE = 96000; @@ -132,6 +138,105 @@ std::vector gen_packet_strm_tone(float freq_hz, float gain, int cha return stream_packets; } +#ifdef OAN_UID_AUTOCONF +// Persists the autoconfigured UID into the io_sim.json config itself +// under the configured field name. Read-modify-write keeps any other +// fields the user has in the file (tracks etc.) intact. Atomic via +// temp+rename. Errors logged, never thrown. +class JsonFieldUidStore : public IUidStore { +public: + JsonFieldUidStore(std::string path, std::string field) + : m_path(std::move(path)), m_field(std::move(field)) {} + + std::optional load() override { + std::ifstream f(m_path); + if (!f) return std::nullopt; + try { + nlohmann::json doc; + f >> doc; + if (!doc.contains(m_field)) return std::nullopt; + const auto& v = doc.at(m_field); + if (!v.is_number_integer()) return std::nullopt; + int64_t i = v.get(); + if (i < 0 || i > 0xFFFF) return std::nullopt; + return static_cast(i); + } catch (const std::exception& e) { + std::cerr << "JsonFieldUidStore: load from '" << m_path + << "' failed: " << e.what() << std::endl; + return std::nullopt; + } + } + + void save(uint16_t uid) override { + nlohmann::json doc; + { + std::ifstream f(m_path); + if (f) { + try { + f >> doc; + } catch (const std::exception& e) { + std::cerr << "JsonFieldUidStore: parse of '" << m_path + << "' failed, will overwrite: " << e.what() << std::endl; + doc = nlohmann::json::object(); + } + } else { + doc = nlohmann::json::object(); + } + } + doc[m_field] = uid; + + std::string tmp = m_path + ".tmp"; + { + std::ofstream f(tmp, std::ios::trunc); + if (!f) { + std::cerr << "JsonFieldUidStore: open temp '" << tmp + << "' for write failed." << std::endl; + return; + } + f << doc.dump(2) << '\n'; + } + std::error_code ec; + std::filesystem::rename(tmp, m_path, ec); + if (ec) { + std::cerr << "JsonFieldUidStore: rename to '" << m_path + << "' failed: " << ec.message() << std::endl; + std::error_code ec2; + std::filesystem::remove(tmp, ec2); + } + } + + void clear() override { + std::ifstream f(m_path); + if (!f) return; + nlohmann::json doc; + try { + f >> doc; + } catch (...) { + return; + } + if (!doc.contains(m_field)) return; + doc.erase(m_field); + + std::string tmp = m_path + ".tmp"; + { + std::ofstream of(tmp, std::ios::trunc); + if (!of) return; + of << doc.dump(2) << '\n'; + } + std::error_code ec; + std::filesystem::rename(tmp, m_path, ec); + if (ec) { + std::error_code ec2; + std::filesystem::remove(tmp, ec2); + } + } + +private: + std::string m_path; + std::string m_field; +}; +#endif + static void print_usage() { std::cout << "io_simulator — looping audio source for the OALS dev stack\n" @@ -145,26 +250,37 @@ static void print_usage() { " Defaults to ./io_sim.json. Example template in\n" " io_sim/io_sim.example.json.\n" "\n" + " --renumber Clear persisted UID before boot, then autoconfigure\n" + " from scratch.\n" " --help Show this message.\n" "\n" "Loops the configured tracks (tone or .wav stems) onto the OAN audio\n" - "EtherType at 96 kHz, advertising itself as an AUDIO_IO_INTERFACE\n" - "with uid from the config (default 1) and acting as a ClockSlave to\n" - "whatever ClockMaster is on the segment.\n"; + "EtherType at 96 kHz, advertising itself as an AUDIO_IO_INTERFACE.\n" + "UID is autoconfigured at first boot and persisted into the config\n" + "file as 'persisted_uid'. Set 'uid' to a static-range value (0xF000-\n" + "0xFFFE) to pin manually. Acts as a ClockSlave to whatever ClockMaster\n" + "is on the segment.\n"; } int main(int argc, char* argv[]) { - if (argc > 1) { - std::string a = argv[1]; + bool renumber = false; + std::vector positional; + for (int i = 1; i < argc; ++i) { + std::string a = argv[i]; if (a == "--help" || a == "-h") { print_usage(); return 0; } + if (a == "--renumber") { + renumber = true; + continue; + } + positional.push_back(std::move(a)); } std::cout << "OpenAudioLive IO Emulator" << std::endl; - const std::string config_path = (argc > 2) ? argv[2] : "io_sim.json"; + const std::string config_path = (positional.size() > 1) ? positional[1] : "io_sim.json"; nlohmann::json cfg; { @@ -184,14 +300,20 @@ int main(int argc, char* argv[]) { } PeerConf conf{}; - conf.iface = (argc > 1) ? argv[1] : "virbr0"; + conf.iface = (!positional.empty()) ? positional[0] : "virbr0"; const char name[32] = "IOSIM"; memcpy(&conf.dev_name, name, strlen(name)); conf.sample_rate = SamplingRate::SAMPLING_96K; conf.dev_type = DeviceType::AUDIO_IO_INTERFACE; +#ifdef OAN_UID_AUTOCONF + // Static-range hints in the config are honoured by the autoconfigurator; + // dynamic-range hints are ignored with a warning. 0 = no hint. + conf.uid = cfg.value("uid", 0); +#else conf.uid = cfg.value("uid", 1); +#endif conf.topo.phy_in_count = 4; conf.topo.phy_out_count = 4; conf.topo.pipes_count = 1; @@ -201,20 +323,39 @@ int main(int argc, char* argv[]) { std::shared_ptr nmapper = std::make_shared(conf); std::cout << "Initializing on " << conf.iface << std::endl; - if(nmapper->init_mapper(conf.iface)) { - nmapper->launch_mapping_process(); - } else { + if(!nmapper->init_mapper(conf.iface)) { std::cerr << "Failed to init mapper" << std::endl; exit(-1); } +#ifdef OAN_UID_AUTOCONF + JsonFieldUidStore file_store{config_path, "persisted_uid"}; + if (renumber) { + std::cout << "--renumber: clearing persisted UID in " + << config_path << std::endl; + file_store.clear(); + } + EnvOverrideUidStore uid_store{"OAN_PERSISTED_UID", + std::make_unique(config_path, "persisted_uid")}; + uint16_t committed = nmapper->autoconfigure_uid(uid_store); + if (committed == 0) { + std::cerr << "io_sim: UID autoconfiguration failed." << std::endl; + return -1; + } + conf.uid = committed; +#else + (void)renumber; +#endif + + nmapper->launch_mapping_process(); + LowLatSocket audio_iface(conf.uid, nmapper); audio_iface.init_socket(conf.iface, EthProtocol::ETH_PROTO_OANAUDIO); LowLatSocket control_iface(conf.uid, nmapper); control_iface.init_socket(conf.iface, EthProtocol::ETH_PROTO_OANCONTROL); - ClockSlave cs{1, conf.iface, nmapper}; + ClockSlave cs{conf.uid, conf.iface, nmapper}; oals::rt::set_process_scheduler_rr(99); From 48ff5caac3b7e3bd77af11ba5519289f9600ef15 Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Thu, 4 Jun 2026 03:43:51 +0200 Subject: [PATCH 3/4] Drop OAN_UID_AUTOCONF flag from console + bump OAN (phase 4) UID autoconfiguration is now unconditional in engine, coreui, and io_sim. Removes the option() in top-level CMakeLists.txt, the #ifdef blocks across all three binaries, and the legacy uid=100/uid=200/uid=1 fallback paths. - engine/NetMan: init_netman is now a single overload that always takes an IUidStore* (nullable for tests). Deleted the m_pconf.uid = 100 branch. - engine/main: unconditional FileUidStore + EnvOverrideUidStore wiring. - coreui/ShowManager: unconditional QJsonFieldUidStore over surface.json. network.uid is honoured only as a static-range pin; dynamic values are silently ignored by the configurator. - io_sim: unconditional JsonFieldUidStore over the config JSON. - surface.json: strip the now-meaningless uid=200 default. - example.json: update the comment to clarify static-pin behaviour. Bumps OAN submodule to 1b824ac (the matching OAN-side phase 4 commit). Firmwares are unaffected; they pin pre-phase-1 OAN and will autoconfigure once their submodules get bumped in phase 3. --- CMakeLists.txt | 15 --------------- OpenAudioNetwork | 2 +- coreui/core/ShowManager.cpp | 14 -------------- coreui/surface_config/surface.json | 3 +-- engine/NetMan.cpp | 14 ++------------ engine/NetMan.h | 10 ++-------- engine/main.cpp | 12 ------------ io_sim/io_sim.example.json | 2 +- io_sim/main.cpp | 13 ------------- 9 files changed, 7 insertions(+), 78 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index dab3be4..dc56792 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,21 +16,6 @@ if(OAN_HOST_BACKENDS) add_compile_definitions(OAN_HOST_BACKENDS) endif() -# UID autoconfiguration: on by default for Linux production + host-backend -# dev. Off elsewhere (e.g. firmware-style toolchain where the build picks up -# this top-level CMake — unusual, but the firmwares vendor OAN directly and -# do their own flag wiring in phase 3). -if(CMAKE_SYSTEM_NAME STREQUAL "Linux" OR OAN_HOST_BACKENDS) - set(_OAN_UID_AUTOCONF_DEFAULT ON) -else() - set(_OAN_UID_AUTOCONF_DEFAULT OFF) -endif() -option(OAN_UID_AUTOCONF "Enable UID autoconfiguration" ${_OAN_UID_AUTOCONF_DEFAULT}) -if(OAN_UID_AUTOCONF) - add_compile_definitions(OAN_UID_AUTOCONF) -endif() -message(STATUS "OAN_UID_AUTOCONF = ${OAN_UID_AUTOCONF}") - if(APPLE) # macOS root volume is read-only (SIP), so the Linux target's /core_plugins # path can't be created. Use a per-user dir under $HOME instead — keeps diff --git a/OpenAudioNetwork b/OpenAudioNetwork index 65c86b7..1b824ac 160000 --- a/OpenAudioNetwork +++ b/OpenAudioNetwork @@ -1 +1 @@ -Subproject commit 65c86b7a6be7d0e5de7d63f8dd1156048393ef52 +Subproject commit 1b824ac0ba0f40132f85c38b8061231aa7e9c3af diff --git a/coreui/core/ShowManager.cpp b/coreui/core/ShowManager.cpp index a9772e0..04da5e3 100644 --- a/coreui/core/ShowManager.cpp +++ b/coreui/core/ShowManager.cpp @@ -5,16 +5,13 @@ #include "ShowManager.h" -#ifdef OAN_UID_AUTOCONF #include "OpenAudioNetwork/common/UidStore.h" #include #include -#endif #include -#ifdef OAN_UID_AUTOCONF namespace { // Persist the autoconfigured UID into a top-level field of an existing @@ -97,7 +94,6 @@ bool is_static_range(uint16_t uid) { } } // namespace -#endif ShowManager::ShowManager() : QObject(nullptr) { m_netconfig = NetworkConfig{}; @@ -114,15 +110,9 @@ bool ShowManager::init_console(SignalWindow* sw) { infos.dev_type = DeviceType::CONTROL_SURFACE; infos.iface = m_netconfig.eth_interface; infos.sample_rate = SamplingRate::SAMPLING_96K; -#ifdef OAN_UID_AUTOCONF // Hint = static-range pin only; dynamic-range hints are ignored // by the configurator (per design §2.5). infos.uid = is_static_range(m_netconfig.hint_uid) ? m_netconfig.hint_uid : 0; -#else - // Pre-autoconfig path: honour whatever was in the config, fall back - // to the historical 200 if nothing was set. - infos.uid = m_netconfig.hint_uid != 0 ? m_netconfig.hint_uid : 200; -#endif infos.topo.phy_in_count = 0; infos.topo.phy_out_count = 0; infos.topo.pipes_count = 0; @@ -134,7 +124,6 @@ bool ShowManager::init_console(SignalWindow* sw) { return false; } -#ifdef OAN_UID_AUTOCONF if (!is_static_range(m_netconfig.hint_uid)) { // No static pin — run autoconfig, persist to surface.json. auto backing = std::make_unique( @@ -157,9 +146,6 @@ bool ShowManager::init_console(SignalWindow* sw) { << m_netconfig.hint_uid << std::dec << " pinned; autoconfig skipped." << std::endl; } -#else - m_netconfig.uid = infos.uid; -#endif std::cout << "Starting netmapper and router processes on interface " << infos.iface << std::endl; diff --git a/coreui/surface_config/surface.json b/coreui/surface_config/surface.json index 60810d9..747e20d 100644 --- a/coreui/surface_config/surface.json +++ b/coreui/surface_config/surface.json @@ -1,8 +1,7 @@ { "network": { "eth_interface": "sim:default", - "persisted_uid": 21190, - "uid": 200 + "persisted_uid": 21190 }, "plugins": { "search_paths": [ diff --git a/engine/NetMan.cpp b/engine/NetMan.cpp index 4922d9d..39527b5 100644 --- a/engine/NetMan.cpp +++ b/engine/NetMan.cpp @@ -15,20 +15,12 @@ NetMan::~NetMan() { } -#ifdef OAN_UID_AUTOCONF bool NetMan::init_netman(const std::string& iface, IUidStore* uid_store) { -#else -bool NetMan::init_netman(const std::string& iface) { -#endif m_pconf = PeerConf{}; m_pconf.dev_type = DeviceType::AUDIO_DSP; m_pconf.sample_rate = SamplingRate::SAMPLING_96K; m_pconf.topo = NodeTopology{0, 0, 64, 0xFFFFFFFFFFFFFFFF}; -#ifdef OAN_UID_AUTOCONF m_pconf.uid = 0; // 0 = "no hint", let the configurator pick. -#else - m_pconf.uid = 100; -#endif m_pconf.iface = iface; m_pconf.ck_type = CKTYPE_MASTER; @@ -41,7 +33,6 @@ bool NetMan::init_netman(const std::string& iface) { return false; } -#ifdef OAN_UID_AUTOCONF if (uid_store) { uint16_t committed = m_nmapper->autoconfigure_uid(*uid_store); if (committed == 0) { @@ -50,11 +41,10 @@ bool NetMan::init_netman(const std::string& iface) { } m_pconf.uid = committed; } else { - // No store passed: autoconfig flag is on but caller opted out. Use - // a sentinel that downstream sockets can still construct against. + // No store: caller (e.g. tests) accepts whatever PeerConf::uid was + // pre-seeded with. Mirror it back out of the mapper for consistency. m_pconf.uid = m_nmapper->committed_uid(); } -#endif m_dsp_control = std::make_unique(m_pconf.uid, m_nmapper); if (!m_dsp_control->init_socket(m_pconf.iface, EthProtocol::ETH_PROTO_OANCONTROL)) { diff --git a/engine/NetMan.h b/engine/NetMan.h index 6643516..4efc438 100644 --- a/engine/NetMan.h +++ b/engine/NetMan.h @@ -15,9 +15,7 @@ #include "piping/AudioPlumber.h" #include "log.h" -#ifdef OAN_UID_AUTOCONF #include "OpenAudioNetwork/common/UidStore.h" -#endif #include @@ -27,14 +25,10 @@ class NetMan { NetMan(AudioPlumber* plumber); ~NetMan(); -#ifdef OAN_UID_AUTOCONF // Init the network manager and run UID autoconfiguration against the - // given store. Store may be null to skip autoconfig (kept for tests - // and the flag-off code path that doesn't link this overload). + // given store. Store may be null to skip autoconfig (e.g. for tests + // that want a deterministic UID via PeerConf). bool init_netman(const std::string& iface, IUidStore* uid_store); -#else - bool init_netman(const std::string& iface); -#endif uint16_t committed_uid() const { return m_pconf.uid; } diff --git a/engine/main.cpp b/engine/main.cpp index e535789..dad2f82 100644 --- a/engine/main.cpp +++ b/engine/main.cpp @@ -24,14 +24,10 @@ #include "OpenAudioNetwork/common/AudioRouter.h" #include "OpenAudioNetwork/common/ClockMaster.h" - -#ifdef OAN_UID_AUTOCONF #include "OpenAudioNetwork/common/UidStore.h" -#endif #include "OpenAudioNetwork/netutils/platform/rt.h" -#ifdef OAN_UID_AUTOCONF namespace { std::string sanitise_iface(const std::string& iface) { @@ -54,7 +50,6 @@ std::string engine_uid_path(const std::string& iface) { } } // namespace -#endif static void print_usage() { std::cout << @@ -101,7 +96,6 @@ int main(int argc, char* argv[]) { AudioEngine audio_engine{}; NetMan nman{&plumber}; -#ifdef OAN_UID_AUTOCONF auto file_store = std::make_unique(engine_uid_path(eth_interface)); if (renumber) { std::cout << LOG_PREFIX << "--renumber: clearing persisted UID at " @@ -114,12 +108,6 @@ int main(int argc, char* argv[]) { std::cerr << LOG_PREFIX << "Failed to initialize network manager." << std::endl; return -1; } -#else - (void)renumber; - if (!nman.init_netman(eth_interface)) { - std::cerr << LOG_PREFIX << "Failed to initialize network manager." << std::endl; - } -#endif AudioRouter router{nman.committed_uid()}; diff --git a/io_sim/io_sim.example.json b/io_sim/io_sim.example.json index d8915a9..8a69214 100644 --- a/io_sim/io_sim.example.json +++ b/io_sim/io_sim.example.json @@ -1,5 +1,5 @@ { - "_comment_uid": "UID is autoconfigured at first boot and written back as 'persisted_uid'. To pin a static UID, set 'uid' to a value in 0xF000-0xFFFE. To re-derive, run with --renumber.", + "_comment_uid": "UID is autoconfigured at first boot and written back as 'persisted_uid'. To pin a static UID, set a top-level 'uid' to a value in 0xF000-0xFFFE (dynamic-range values are ignored). To re-derive, run with --renumber.", "tracks": [ { "channel": 0, "tone": { "freq": 440.0, "gain": 0.3 } }, { "channel": 1, "tone": { "freq": 880.0, "gain": 0.3 } }, diff --git a/io_sim/main.cpp b/io_sim/main.cpp index bbe0932..32e648e 100644 --- a/io_sim/main.cpp +++ b/io_sim/main.cpp @@ -21,10 +21,7 @@ #include #include #include - -#ifdef OAN_UID_AUTOCONF #include -#endif #include @@ -138,7 +135,6 @@ std::vector gen_packet_strm_tone(float freq_hz, float gain, int cha return stream_packets; } -#ifdef OAN_UID_AUTOCONF // Persists the autoconfigured UID into the io_sim.json config itself // under the configured field name. Read-modify-write keeps any other // fields the user has in the file (tracks etc.) intact. Atomic via @@ -235,7 +231,6 @@ class JsonFieldUidStore : public IUidStore { std::string m_path; std::string m_field; }; -#endif static void print_usage() { std::cout << @@ -307,13 +302,9 @@ int main(int argc, char* argv[]) { conf.sample_rate = SamplingRate::SAMPLING_96K; conf.dev_type = DeviceType::AUDIO_IO_INTERFACE; -#ifdef OAN_UID_AUTOCONF // Static-range hints in the config are honoured by the autoconfigurator; // dynamic-range hints are ignored with a warning. 0 = no hint. conf.uid = cfg.value("uid", 0); -#else - conf.uid = cfg.value("uid", 1); -#endif conf.topo.phy_in_count = 4; conf.topo.phy_out_count = 4; conf.topo.pipes_count = 1; @@ -328,7 +319,6 @@ int main(int argc, char* argv[]) { exit(-1); } -#ifdef OAN_UID_AUTOCONF JsonFieldUidStore file_store{config_path, "persisted_uid"}; if (renumber) { std::cout << "--renumber: clearing persisted UID in " @@ -343,9 +333,6 @@ int main(int argc, char* argv[]) { return -1; } conf.uid = committed; -#else - (void)renumber; -#endif nmapper->launch_mapping_process(); From 9e3d1640211cbaf83b8dff66c61f4714830a9f0f Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Thu, 4 Jun 2026 11:01:02 +0200 Subject: [PATCH 4/4] sim_switch: adopt conn UID from MAPPING payload on disco A peer's discovery socket is created by NetworkMapper before UID autoconfig runs, so the hello on that conn carries uid=0. The committed UID first appears on the wire inside MAPPING packets sent on the same conn. Without adoption, every engine's disco conn stays registered as uid=0 in the switch for the rest of the session, which breaks unicast disco routing (last-zero-wins in the route table) and makes the TUI aggregate every engine into one fake "uid=0 (unknown)" peer. When a MAPPING packet arrives on a non-promiscuous disco conn whose self_uid is still 0, parse the payload's self_uid and adopt it: drop the stale (disco, 0) route entry if it still points to us, install the new (disco, learned_uid) entry, and let the subsequent src_uid overwrite + peer-stats attribution use the real identity. Verified end-to-end: two engines on one sim_switch now each surface under their autoconfigured UIDs in oaninspect's disco stream instead of both showing src_uid=0. --- tools/sim_switch/Switch.cpp | 46 ++++++++++++++++++++- tools/sim_switch/test/test_sim_switch.cpp | 49 +++++++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/tools/sim_switch/Switch.cpp b/tools/sim_switch/Switch.cpp index 476c0b5..755a26f 100644 --- a/tools/sim_switch/Switch.cpp +++ b/tools/sim_switch/Switch.cpp @@ -9,11 +9,31 @@ #include #include +#include "common/packet_structs.h" + // Per-conn rx_buf must fit at least one max-size frame. AudioPacket framing on // the wire is ~360 bytes; budget generously for future EtherTypes too. constexpr size_t MAX_FRAME_PAYLOAD = 8192; constexpr size_t RX_READ_CHUNK = 4096; +// Layout the disco peer puts on the wire (matches DiscoveryPeek): [eth 14] +// [LowLatHeader 6][OANPacket]. self_uid lives inside the +// MappingData payload. +namespace { +constexpr size_t DISCO_LL_PREFIX = 14 + 6; + +// Extract self_uid from a disco frame payload if it parses as a MAPPING +// packet; returns 0 otherwise (also for genuinely uid=0 packets, which is +// fine — uid=0 means "unknown" in our adoption flow). +uint16_t parse_mapping_self_uid(const uint8_t* payload, size_t len) { + if (len < DISCO_LL_PREFIX + sizeof(OANPacket)) return 0; + OANPacket pck{}; + std::memcpy(&pck, payload + DISCO_LL_PREFIX, sizeof(pck)); + if (pck.header.type != PacketType::MAPPING) return 0; + return pck.packet_data.self_uid; +} +} // namespace + Switch::EtypeIdx Switch::etype_index(uint16_t e) { switch (e) { case ETH_PROTO_OANAUDIO: return EtypeIdx::AUDIO; @@ -149,6 +169,30 @@ int Switch::consume_frame(Conn& c) { if (c.rx_buf.size() < sizeof(SimFrame) + hdr.payload_len) return 0; + const uint8_t* payload = c.rx_buf.data() + sizeof(SimFrame); + + // Conn-uid adoption: NetworkMapper creates its discovery socket *before* + // UID autoconfig runs, so the hello on that conn carries uid=0. The + // committed UID first shows up in the MAPPING packet payload. Adopt it + // here so per-peer stats, src_uid stamping, and the route table all see + // the right identity for the rest of the session. + if (!c.promiscuous && c.self_uid == 0 + && hdr.ethertype == ETH_PROTO_OANDISCO) { + uint16_t learned = parse_mapping_self_uid(payload, hdr.payload_len); + if (learned != 0) { + // Drop the bogus (disco, 0) route only if it still points to us. + // Another zero-uid conn may have overwritten it at hello time. + uint32_t old_key = (uint32_t(ETH_PROTO_OANDISCO) << 16) | 0u; + auto old_it = m_route_table.find(old_key); + if (old_it != m_route_table.end() && old_it->second == c.fd) { + m_route_table.erase(old_it); + } + c.self_uid = learned; + uint32_t new_key = (uint32_t(ETH_PROTO_OANDISCO) << 16) | learned; + m_route_table[new_key] = c.fd; + } + } + // The switch owns src_uid attribution — sender's value is overwritten // with the conn's registered uid so observers can trust it. hdr.src_uid = c.self_uid; @@ -158,8 +202,6 @@ int Switch::consume_frame(Conn& c) { // defensive in case someone later refactors to splat raw bytes. std::memcpy(c.rx_buf.data(), &hdr, sizeof(hdr)); - const uint8_t* payload = c.rx_buf.data() + sizeof(SimFrame); - // Stats int idx = (int)etype_index(hdr.ethertype); m_stats.frames_in[idx]++; diff --git a/tools/sim_switch/test/test_sim_switch.cpp b/tools/sim_switch/test/test_sim_switch.cpp index 90d8928..6fa67df 100644 --- a/tools/sim_switch/test/test_sim_switch.cpp +++ b/tools/sim_switch/test/test_sim_switch.cpp @@ -555,6 +555,55 @@ TEST(SimSwitch, V1HelloRejected) { EXPECT_EQ(::read(a.fd(), &buf, 1), 0); // EOF from server } +// 16. Disco conn UID adoption. NetworkMapper creates its discovery socket +// before UID autoconfig runs, so the hello on that conn carries uid=0. Once +// the autoconfigured UID first appears in a MAPPING packet, the switch must +// adopt it as the conn's identity — otherwise unicast disco to that peer +// goes nowhere and the TUI shows every engine aggregated as uid=0. +#include "common/packet_structs.h" +TEST(SimSwitch, DiscoConnAdoptsUidFromMappingPayload) { + auto path = make_test_socket_path(); + DaemonProc d(path); + ASSERT_TRUE(d.ready()); + RawClient a, b, insp; + ASSERT_TRUE(a.connect(path)); + ASSERT_TRUE(b.connect(path)); + ASSERT_TRUE(insp.connect(path)); + + // A hellos with uid=0 (the bootstrap-conn case). B is a normal peer. + a.send_hello(SIM_MAGIC, ETH_PROTO_OANDISCO, 0); + b.send_hello(SIM_MAGIC, ETH_PROTO_OANDISCO, 99); + insp.send_hello(SIM_MAGIC, 0, 0xFFFE, SIM_HELLO_PROMISCUOUS); + std::this_thread::sleep_for(50ms); + + // A broadcasts a MAPPING packet announcing committed_uid=42. Match the + // wire layout DiscoveryPeek parses: [eth 14][LowLatHeader 6][OANPacket]. + constexpr uint16_t COMMITTED_UID = 42; + constexpr size_t PREFIX = 14 + 6; + std::vector payload(PREFIX + sizeof(OANPacket), 0); + OANPacket pck{}; + pck.header.type = PacketType::MAPPING; + pck.packet_data.self_uid = COMMITTED_UID; + std::memcpy(payload.data() + PREFIX, &pck, sizeof(pck)); + ASSERT_TRUE(a.send_frame(ETH_PROTO_OANDISCO, /*dest=*/0, payload)); + + // Inspector confirms the broadcast went out with src_uid=42, proving + // the conn's identity flipped from 0 to 42 in the switch's bookkeeping. + auto got = insp.read_frame_full(500); + ASSERT_TRUE(got.ok); + EXPECT_EQ(got.hdr.src_uid, COMMITTED_UID); + + // B unicasts disco to dest=42. This only routes if the switch added a + // (disco, 42) → A entry to the route table — the second half of adoption. + std::vector hello_a(8, 0xAB); + ASSERT_TRUE(b.send_frame(ETH_PROTO_OANDISCO, COMMITTED_UID, hello_a)); + auto from_b = a.read_frame_full(500); + ASSERT_TRUE(from_b.ok); + EXPECT_EQ(from_b.hdr.src_uid, 99); + EXPECT_EQ(from_b.hdr.dest_uid, COMMITTED_UID); + EXPECT_EQ(from_b.body, hello_a); +} + // ------ Filter parser unit tests ------------------------------------------ // These exercise the oaninspect filter expression parser as a pure unit, // no daemon/process involved.