From fdcf7b83f382a80e6e3e93a269593570c401125b Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Wed, 3 Jun 2026 14:51:10 +0200 Subject: [PATCH 01/15] Migrate engine + io_sim to oals::rt shim (M1 of dev-tooling plan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace inline sched_setscheduler/sched_setaffinity/clock_nanosleep calls with calls into the new oals::rt namespace from OpenAudioNetwork/netutils/platform/rt.h. Delete the local set_thread_realtime/set_running_cpu helpers and the #include in both files. No behavior change on Linux: same SCHED_FIFO priorities (engine 25/20/80, io_sim 50), same SCHED_RR (99, io_sim main), same CPU affinities (engine 1/1/2/3), same CLOCK_MONOTONIC relative sleeps (100 ns + 10000 ns in engine, ~667 µs in io_sim). Plus bumped OpenAudioNetwork submodule pointer to the matching M1 commit that introduces the shim and the NOT EMBEDDED_BUILD gate. After M1+M2 the engine and io_sim will compile on macOS; linking still depends on M3's transport seam (the four extern "C" hooks that LowLatSocket's #else arm currently references but doesn't define on non-Linux hosts). See Docs/dev-tooling-plan.md M1. --- OpenAudioNetwork | 2 +- engine/main.cpp | 47 ++++++++++------------------------------------- io_sim/main.cpp | 23 +++++------------------ 3 files changed, 16 insertions(+), 56 deletions(-) diff --git a/OpenAudioNetwork b/OpenAudioNetwork index 3295bc0..172a42d 160000 --- a/OpenAudioNetwork +++ b/OpenAudioNetwork @@ -1 +1 @@ -Subproject commit 3295bc0ec7bdfe16822798379a17b1df95504891 +Subproject commit 172a42d4e4b49ffae27379e4ebfec0de6c4d3aa8 diff --git a/engine/main.cpp b/engine/main.cpp index 6af85a5..4cb5959 100644 --- a/engine/main.cpp +++ b/engine/main.cpp @@ -19,26 +19,7 @@ #include "OpenAudioNetwork/common/AudioRouter.h" #include "OpenAudioNetwork/common/ClockMaster.h" -#include "linux/sched.h" - -void set_thread_realtime(uint8_t prio) { - sched_param sparams{}; - sparams.sched_priority = prio; - - if (sched_setscheduler(0, SCHED_FIFO, &sparams) == -1) { - std::cerr << "Failed to set thread realtime..." << std::endl; - } -} - -void set_running_cpu(int cpu_id) { - cpu_set_t cs{}; - CPU_ZERO(&cs); - CPU_SET(cpu_id, &cs); - - if (sched_setaffinity(0, sizeof(cpu_set_t), &cs) != 0) { - std::cerr << "Failed to set affinity..." << std::endl; - } -} +#include "OpenAudioNetwork/netutils/platform/rt.h" int main(int argc, char* argv[]) { @@ -121,8 +102,8 @@ int main(int argc, char* argv[]) { load_plugins(ploader, &plumber, &router, nman.get_net_mapper()); std::thread audiopoll_thread = std::thread([&router]() { - set_thread_realtime(25); - set_running_cpu(1); + oals::rt::set_thread_realtime(25); + oals::rt::set_running_cpu(1); while (true) { router.poll_audio_data(false); @@ -130,8 +111,8 @@ int main(int argc, char* argv[]) { }); std::thread controlpoll_thread = std::thread([&router]() { - set_thread_realtime(20); - set_running_cpu(1); + oals::rt::set_thread_realtime(20); + oals::rt::set_running_cpu(1); while (true) { router.poll_control_packets(false); @@ -139,12 +120,8 @@ int main(int argc, char* argv[]) { }); std::thread pipe_updater = std::thread([&audio_engine, &router]() { - set_thread_realtime(80); - set_running_cpu(2); - - timespec thread_wait_time{}; - thread_wait_time.tv_sec = 0; - thread_wait_time.tv_nsec = 100; + oals::rt::set_thread_realtime(80); + oals::rt::set_running_cpu(2); while (true) { router.poll_local_audio_buffer(); @@ -153,20 +130,16 @@ int main(int argc, char* argv[]) { // This process is a high-priority realtime process // It is a blocking task, to let the other threads run // I must add a small wait here - clock_nanosleep(CLOCK_MONOTONIC, 0, &thread_wait_time, nullptr); + oals::rt::precise_sleep_ns(100); } }); std::thread clock_syncer = std::thread([&nman]() { - set_running_cpu(3); - - timespec thread_wait_time{}; - thread_wait_time.tv_sec = 0; - thread_wait_time.tv_nsec = 10000; + oals::rt::set_running_cpu(3); while (true) { nman.clock_master_process(); - clock_nanosleep(CLOCK_MONOTONIC, 0, &thread_wait_time, nullptr); + oals::rt::precise_sleep_ns(10000); } }); diff --git a/io_sim/main.cpp b/io_sim/main.cpp index 1ebdbc2..9c7c0ac 100644 --- a/io_sim/main.cpp +++ b/io_sim/main.cpp @@ -16,16 +16,7 @@ #include #include -#include - -void set_thread_realtime(uint8_t prio) { - sched_param sparams{}; - sparams.sched_priority = prio; - - if (sched_setscheduler(0, SCHED_FIFO, &sparams) != 0) { - std::cerr << "Failed to set thread realtime..." << std::endl; - } -} +#include float sig_gen(float f, float gain, int n) { constexpr float T = 1.0f / 96000.0f; @@ -170,7 +161,7 @@ int main(int argc, char* argv[]) { /* std::thread playback_thread = std::thread([&audio_iface, sound_handle, &cs]() { - set_thread_realtime(50); + oals::rt::set_thread_realtime(50); std::queue audio_buffer; LowLatPacket rx_packet{}; @@ -237,11 +228,7 @@ int main(int argc, char* argv[]) { playback_thread.detach(); */ - sched_param params{}; - params.sched_priority = 99; - if (sched_setscheduler(0, SCHED_RR, ¶ms) != 0) { - std::cerr << "FAILED TO SET SCHED" << std::endl; - } + oals::rt::set_process_scheduler_rr(99); auto wait_base = (long)((AUDIO_DATA_SAMPLES_PER_PACKETS * (1.0f / 96000.0f)) * 1e9); @@ -277,7 +264,7 @@ int main(int argc, char* argv[]) { chann++; } - set_thread_realtime(50); + oals::rt::set_thread_realtime(50); std::cout << "START" << std::endl; @@ -301,7 +288,7 @@ int main(int argc, char* argv[]) { //last_stamp = now; - clock_nanosleep(CLOCK_MONOTONIC, 0, &ts, nullptr); + oals::rt::precise_sleep_ns(ts.tv_nsec); ts.tv_nsec = wait_base; } From d974e603960690f7b324b2812fabd15b6afdd300 Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Wed, 3 Jun 2026 15:58:47 +0200 Subject: [PATCH 02/15] Make engine + io_sim compile on macOS (M2 of dev-tooling plan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CMake plumbing + source hygiene so the OALS tree configures and compiles on macOS up to the M3-scope link gap. Linking still depends on M3's transport seam (__send_data / __recv_data / __fetch_iface_meta and NetworkMapper::get_mac_by_uid remain undefined on non-Linux hosts). Top-level CMakeLists.txt: - Gate the aarch64 / -ftree-vectorize block with AND NOT APPLE so it no longer fires on Apple Silicon (which reports as aarch64 on some toolchains, arm64 on others; either way Apple Clang auto-vectorizes at -O2+, so the flag is redundant). Linux ARM (Pi, embedded) still picks it up. - Add option(OAN_HOST_BACKENDS "Build host dev transports + RT shim" OFF), force ON via CACHE BOOL ... FORCE when APPLE so subdirectory scopes see the override (a plain set() shadows but does not update the cache), propagate as add_compile_definitions. No code consumes the macro yet — it is plumbing for M3 transport sources. io_sim ALSA dead-code purge: The dev-tooling plan claimed io_sim's ALSA use was "already entirely commented out." That was wrong — only the call and the playback_thread consuming it were commented; the snd_pcm_t* alsa_setup() function body and #include were live. Decision was to delete outright rather than wrap in #ifdef __linux__: io_sim is marked deprecated in CLAUDE.md, the VSC track supersedes its playback path, and ALSA cannot exist on macOS. Removes -107 lines from io_sim/main.cpp plus the ALSA pkg_check_modules and link in io_sim/CMakeLists.txt. SNDFILE stays REQUIRED (used live by gen_packet_strm_from_file for stem playback; available on macOS via brew install libsndfile). SNDFILE include-dir propagation: io_sim and debugger now pass ${SNDFILE_INCLUDE_DIRS} into target_include_directories. Linux happened to work because /usr/include is searched by default; Homebrew installs to /opt/homebrew/include which is not. Qt include de-Debianisation in plugins/loader/PipeDesc.h: #include etc. was Debian/Ubuntu-specific (their Qt6 headers live under /usr/include/qt6/). Standard Qt6 CMake config — used by Homebrew, Arch, Fedora, Alpine, and upstream Qt — adds the include dir directly and expects the canonical form. Linux Debian/Ubuntu users keep working because Qt6::Widgets is already linked everywhere these headers are used. Two missing #include : engine/NetMan.cpp and debugger/DebuggerWindow.cpp use std::cerr / std::cout but did not include . Previously transitive via other headers; the Mac build path no longer pulls it in. Direct include is correct hygiene. Plus .gitignore for build/ dirs. Verified on macOS: cmake configure + cmake --build produces 72 .cpp.o files with 0 compile errors. Only failures are the documented M3-scope undefined symbols at the oannetutils.dylib link step. See Docs/dev-tooling-plan.md M2. --- .gitignore | 1 + CMakeLists.txt | 10 +++- debugger/CMakeLists.txt | 2 +- debugger/DebuggerWindow.cpp | 2 + engine/NetMan.cpp | 2 + io_sim/CMakeLists.txt | 4 +- io_sim/main.cpp | 107 ------------------------------------ plugins/loader/PipeDesc.h | 8 +-- 8 files changed, 20 insertions(+), 116 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdf319e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/build/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 805c8ee..5e09fd0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,11 +3,19 @@ project(OALiveSystem) set(CMAKE_CXX_STANDARD 20) -if(${CMAKE_SYSTEM_PROCESSOR} STREQUAL "aarch64") +if(${CMAKE_SYSTEM_PROCESSOR} STREQUAL "aarch64" AND NOT APPLE) message("Added arm gcc optim") add_compile_options(-ftree-vectorize) endif() +option(OAN_HOST_BACKENDS "Build host dev transports + RT shim" OFF) +if(APPLE) + set(OAN_HOST_BACKENDS ON CACHE BOOL "Build host dev transports + RT shim" FORCE) +endif() +if(OAN_HOST_BACKENDS) + add_compile_definitions(OAN_HOST_BACKENDS) +endif() + set(ENGINE_PLUGIN_SYSLOCATION "/core_plugins") add_compile_definitions(ENGINE_PLUGIN_SYSLOCATION="${ENGINE_PLUGIN_SYSLOCATION}") diff --git a/debugger/CMakeLists.txt b/debugger/CMakeLists.txt index ffb0c73..89dba97 100644 --- a/debugger/CMakeLists.txt +++ b/debugger/CMakeLists.txt @@ -11,7 +11,7 @@ qt_add_executable(debugger DebuggerWindow.ui ) -target_include_directories(debugger PUBLIC ${PROJECT_SOURCE_DIR}) +target_include_directories(debugger PUBLIC ${PROJECT_SOURCE_DIR} ${SNDFILE_INCLUDE_DIRS}) target_link_libraries(debugger PUBLIC Qt6::Core Qt6::Widgets) target_link_libraries(debugger PUBLIC oancommon oannetutils) target_link_libraries(debugger PUBLIC ${SNDFILE_LIBRARIES}) \ No newline at end of file diff --git a/debugger/DebuggerWindow.cpp b/debugger/DebuggerWindow.cpp index 6b1d73a..ec73611 100644 --- a/debugger/DebuggerWindow.cpp +++ b/debugger/DebuggerWindow.cpp @@ -3,6 +3,8 @@ // // This project is distributed under the Creative Commons CC-BY-NC-SA licence. https://creativecommons.org/licenses/by-nc-sa/4.0 +#include + #include "DebuggerWindow.h" #include "ui_DebuggerWindow.h" diff --git a/engine/NetMan.cpp b/engine/NetMan.cpp index 98a8a4f..8857e3d 100644 --- a/engine/NetMan.cpp +++ b/engine/NetMan.cpp @@ -3,6 +3,8 @@ // // This project is distributed under the Creative Commons CC-BY-NC-SA licence. https://creativecommons.org/licenses/by-nc-sa/4.0 +#include + #include "NetMan.h" NetMan::NetMan(AudioPlumber* plumber) { diff --git a/io_sim/CMakeLists.txt b/io_sim/CMakeLists.txt index be526b4..51e63c9 100644 --- a/io_sim/CMakeLists.txt +++ b/io_sim/CMakeLists.txt @@ -3,10 +3,8 @@ add_executable(io_simulator ) find_package(PkgConfig REQUIRED) -pkg_check_modules(ALSA REQUIRED alsa) pkg_check_modules(SNDFILE REQUIRED sndfile) -target_include_directories(io_simulator PUBLIC ${PROJECT_SOURCE_DIR}) +target_include_directories(io_simulator PUBLIC ${PROJECT_SOURCE_DIR} ${SNDFILE_INCLUDE_DIRS}) target_link_libraries(io_simulator oancommon oannetutils) -target_link_libraries(io_simulator ${ALSA_LIBRARIES}) target_link_libraries(io_simulator ${SNDFILE_LIBRARIES}) \ No newline at end of file diff --git a/io_sim/main.cpp b/io_sim/main.cpp index 9c7c0ac..b605c12 100644 --- a/io_sim/main.cpp +++ b/io_sim/main.cpp @@ -9,7 +9,6 @@ #include #include -#include #include #include @@ -53,41 +52,6 @@ AudioPacket make_packet(float f, float sig_level, int& n) { return pck; } -snd_pcm_t* alsa_setup() { - snd_pcm_t* hdl; - snd_pcm_hw_params_t* params; - snd_pcm_sw_params_t* sw_params; - snd_pcm_format_t fmt = SND_PCM_FORMAT_FLOAT; - - auto err = snd_pcm_open(&hdl, "hw:2,0", SND_PCM_STREAM_PLAYBACK, SND_PCM_NONBLOCK); - if (err < 0) { - std::cerr << "ALSA FAIL INIT" << std::endl; - } - - snd_pcm_hw_params_alloca(¶ms); - snd_pcm_hw_params_any(hdl, params); - snd_pcm_hw_params_set_access(hdl, params, SND_PCM_ACCESS_RW_INTERLEAVED); - snd_pcm_hw_params_set_format(hdl, params, SND_PCM_FORMAT_FLOAT); - snd_pcm_hw_params_set_channels(hdl, params, 1); - snd_pcm_hw_params_set_rate(hdl, params, 96000, 0); - - if (snd_pcm_hw_params(hdl, params) < 0) { - std::cerr << "FAILED TO SET HW PARAMS" << std::endl; - } - - snd_pcm_sw_params_malloc(&sw_params); - snd_pcm_sw_params_current(hdl, sw_params); - snd_pcm_sw_params_set_start_threshold(hdl, sw_params, AUDIO_DATA_SAMPLES_PER_PACKETS * 100); - - err = snd_pcm_sw_params(hdl, sw_params); - if (err < 0) { - std::cerr << "FAILED TO SET SW PARAMS : " << err << std::endl; - } - - snd_pcm_prepare(hdl); - return hdl; -} - std::vector gen_packet_strm_from_file(std::string file, int channel) { SF_INFO info{}; SNDFILE* wavfile = sf_open(file.c_str(), SFM_READ, &info); @@ -138,8 +102,6 @@ int main(int argc, char* argv[]) { conf.topo.pipes_count = 1; conf.ck_type = CKTYPE_SLAVE; - //snd_pcm_t* sound_handle = alsa_setup(); - // Init auto-discover mechanism std::shared_ptr nmapper = std::make_shared(conf); @@ -159,75 +121,6 @@ int main(int argc, char* argv[]) { ClockSlave cs{1, conf.iface, nmapper}; - /* - std::thread playback_thread = std::thread([&audio_iface, sound_handle, &cs]() { - oals::rt::set_thread_realtime(50); - - std::queue audio_buffer; - LowLatPacket rx_packet{}; - - auto last_now = local_now_us(); - uint64_t delay_sum = 0; - uint64_t processing_latency_sum = 0; - uint64_t sum_count = 0; - uint64_t max_delay = 0; - uint64_t min_delay = 0xFFFFFFFFFFFFFFFF; - - while (true) { - if (audio_iface.receive_data(&rx_packet) > 0) { - auto pck_data = rx_packet.payload.packet_data; - audio_buffer.emplace(pck_data); - - auto now = local_now_us(); - last_now = now; - - auto now_corrected = now - cs.get_ck_offset(); - auto latency = rx_packet.payload.header.prev_delay + (now_corrected - rx_packet.payload.header.timestamp); - delay_sum += latency; - processing_latency_sum += rx_packet.payload.header.prev_delay; - sum_count++; - - if (latency > max_delay) { - max_delay = latency; - } - - if (latency < min_delay) { - min_delay = latency; - } - } - - if (!audio_buffer.empty()) { - auto& oldest_pck = audio_buffer.front(); - - int err = snd_pcm_writei(sound_handle, &oldest_pck.samples, AUDIO_DATA_SAMPLES_PER_PACKETS); - if (err < 0) { - //std::cerr << "FAIL : " << err << std::endl; - snd_pcm_prepare(sound_handle); - } - - audio_buffer.pop(); - } - - if (sum_count == 3250) { - float avg_latency = (float)delay_sum / sum_count; - float avg_proc_latency = (float)processing_latency_sum / sum_count; - - std::cout << "Avg roudtrip latency " << avg_latency / 1000.0f << " ms"; - std::cout << ", Avg processing latency " << avg_proc_latency / 1000.0f << " ms" << std::endl; - std::cout << "Max : " << (float)max_delay / 1000.0f << " ms, Min : " << (float)min_delay / 1000.0f << " ms" << std::endl; - - sum_count = 0; - delay_sum = 0; - processing_latency_sum = 0; - min_delay = 0xFFFFFFFFFFFFFFFF; - max_delay = 0; - } - } - }); - - playback_thread.detach(); - */ - oals::rt::set_process_scheduler_rr(99); auto wait_base = (long)((AUDIO_DATA_SAMPLES_PER_PACKETS * (1.0f / 96000.0f)) * 1e9); diff --git a/plugins/loader/PipeDesc.h b/plugins/loader/PipeDesc.h index 555304d..9501222 100644 --- a/plugins/loader/PipeDesc.h +++ b/plugins/loader/PipeDesc.h @@ -11,10 +11,10 @@ #include #include -#include -#include -#include -#include +#include +#include +#include +#include #include "ElemControlData.h" #include "OpenAudioNetwork/common/AudioRouter.h" From 8d0cc4e0c0499141a65522153336dd104bc7ce01 Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Wed, 3 Jun 2026 17:05:36 +0200 Subject: [PATCH 03/15] Propagate sndfile lib dirs + bump OAN to M3 (M3 of dev-tooling plan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump OpenAudioNetwork submodule to the M3 commit that introduces the ITransport seam, so OALSEngine, io_simulator, OALSCoreUI, debugger, and the builtin plugins all link cleanly on macOS for the first time (M2 only got them to compile; oannetutils.dylib was failing to link with four undefined symbols — __send_data, __recv_data, __fetch_iface_meta, NetworkMapper::get_mac_by_uid). After M3 the SimTransport stub is reachable: ./OALSEngine sim:default routes through it and exits cleanly with a "not yet implemented (M4)" message. Plain ifname on Mac fails loudly with a clear "this host requires a transport prefix" message. Plus a CMake fix the M3 link surfaced: target_link_directories with ${SNDFILE_LIBRARY_DIRS} on both io_simulator and debugger. M2 added ${SNDFILE_INCLUDE_DIRS} for the compile step but missed the parallel ${SNDFILE_LIBRARY_DIRS} for the link step. On Linux this happened to work because /usr/lib is searched by default; on Mac, Homebrew's /opt/homebrew/lib is not. Without this, `ld: library 'sndfile' not found` blocks io_simulator and debugger from linking. M2 didn't surface this because the M2 link gap stopped earlier in the chain. See OpenAudioLiveSystem/Docs/dev-tooling-plan.md M3 and the OAN submodule's M3 commit (61ed291) for the transport seam details. --- OpenAudioNetwork | 2 +- debugger/CMakeLists.txt | 1 + io_sim/CMakeLists.txt | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/OpenAudioNetwork b/OpenAudioNetwork index 172a42d..61ed291 160000 --- a/OpenAudioNetwork +++ b/OpenAudioNetwork @@ -1 +1 @@ -Subproject commit 172a42d4e4b49ffae27379e4ebfec0de6c4d3aa8 +Subproject commit 61ed291c2608f280b8391b41fb46f8ca55a79dbf diff --git a/debugger/CMakeLists.txt b/debugger/CMakeLists.txt index 89dba97..441c0e0 100644 --- a/debugger/CMakeLists.txt +++ b/debugger/CMakeLists.txt @@ -12,6 +12,7 @@ qt_add_executable(debugger ) target_include_directories(debugger PUBLIC ${PROJECT_SOURCE_DIR} ${SNDFILE_INCLUDE_DIRS}) +target_link_directories(debugger PUBLIC ${SNDFILE_LIBRARY_DIRS}) target_link_libraries(debugger PUBLIC Qt6::Core Qt6::Widgets) target_link_libraries(debugger PUBLIC oancommon oannetutils) target_link_libraries(debugger PUBLIC ${SNDFILE_LIBRARIES}) \ No newline at end of file diff --git a/io_sim/CMakeLists.txt b/io_sim/CMakeLists.txt index 51e63c9..052a4bb 100644 --- a/io_sim/CMakeLists.txt +++ b/io_sim/CMakeLists.txt @@ -6,5 +6,6 @@ find_package(PkgConfig REQUIRED) pkg_check_modules(SNDFILE REQUIRED sndfile) target_include_directories(io_simulator PUBLIC ${PROJECT_SOURCE_DIR} ${SNDFILE_INCLUDE_DIRS}) +target_link_directories(io_simulator PUBLIC ${SNDFILE_LIBRARY_DIRS}) target_link_libraries(io_simulator oancommon oannetutils) target_link_libraries(io_simulator ${SNDFILE_LIBRARIES}) \ No newline at end of file From 63e87d73d1287fc840141bf7e951838d3e982181 Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Wed, 3 Jun 2026 18:49:09 +0200 Subject: [PATCH 04/15] Add sim_switch daemon + macOS dev-loop fixes (M4 of dev-tooling plan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New tools/sim_switch/ — single-threaded poll() user-space switch daemon emulating a switched L2 segment over AF_UNIX. Pure POSIX, builds on Linux + macOS. Live TUI with traffic rates, device discovery decode, per-conn drop counters; --headless mode for CI. 7-scenario GTest smoke suite (hello, broadcast, unicast, unknown-unicast, bad magic, slow client, SimTransport interop). Submodule bump pulls in OAN's real SimTransport implementation. Wire framing (sim_proto.h) is owned here, not in OAN — OAN is vendored into firmware repos and must stay free of host-dev-only contracts. SimTransport keeps a byte-identical local copy. Also in this commit (M4 verification follow-ups discovered on macOS): - ENGINE_PLUGIN_SYSLOCATION is now $HOME/.osst/core_plugins on APPLE; the Linux /core_plugins path can't be created on macOS's sealed root volume. Linux target unchanged. - io_simulator now respects argv[1] as the interface string, matching OALSEngine. Previously hardcoded to 'virbr0'. - coreui/surface_config/surface.json uses sim:default as the default eth_interface so the UI works out of the box on macOS. Linux dev loops can override per-host. - Top-level enable_testing() so ctest discovers sim_switch_smoke reliably across CMake versions. --- CMakeLists.txt | 14 +- OpenAudioNetwork | 2 +- coreui/surface_config/surface.json | 2 +- io_sim/main.cpp | 8 +- tools/sim_switch/CMakeLists.txt | 38 +++ tools/sim_switch/DiscoveryPeek.cpp | 36 +++ tools/sim_switch/DiscoveryPeek.h | 31 +++ tools/sim_switch/Switch.cpp | 229 +++++++++++++++ tools/sim_switch/Switch.h | 85 ++++++ tools/sim_switch/Tui.cpp | 253 +++++++++++++++++ tools/sim_switch/Tui.h | 44 +++ tools/sim_switch/main.cpp | 224 +++++++++++++++ tools/sim_switch/sim_proto.h | 32 +++ tools/sim_switch/test/test_sim_switch.cpp | 321 ++++++++++++++++++++++ 14 files changed, 1313 insertions(+), 6 deletions(-) create mode 100644 tools/sim_switch/CMakeLists.txt create mode 100644 tools/sim_switch/DiscoveryPeek.cpp create mode 100644 tools/sim_switch/DiscoveryPeek.h create mode 100644 tools/sim_switch/Switch.cpp create mode 100644 tools/sim_switch/Switch.h create mode 100644 tools/sim_switch/Tui.cpp create mode 100644 tools/sim_switch/Tui.h create mode 100644 tools/sim_switch/main.cpp create mode 100644 tools/sim_switch/sim_proto.h create mode 100644 tools/sim_switch/test/test_sim_switch.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5e09fd0..7b921be 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,9 +16,18 @@ if(OAN_HOST_BACKENDS) add_compile_definitions(OAN_HOST_BACKENDS) endif() -set(ENGINE_PLUGIN_SYSLOCATION "/core_plugins") +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 + # the dev loop sudo-free. + set(ENGINE_PLUGIN_SYSLOCATION "$ENV{HOME}/.osst/core_plugins") +else() + set(ENGINE_PLUGIN_SYSLOCATION "/core_plugins") +endif() add_compile_definitions(ENGINE_PLUGIN_SYSLOCATION="${ENGINE_PLUGIN_SYSLOCATION}") +enable_testing() + add_subdirectory(OpenDSP) add_subdirectory(OpenAudioNetwork) @@ -26,4 +35,5 @@ add_subdirectory(plugins) add_subdirectory(coreui) add_subdirectory(engine) add_subdirectory(io_sim) -add_subdirectory(debugger) \ No newline at end of file +add_subdirectory(debugger) +add_subdirectory(tools/sim_switch) \ No newline at end of file diff --git a/OpenAudioNetwork b/OpenAudioNetwork index 61ed291..25359b2 160000 --- a/OpenAudioNetwork +++ b/OpenAudioNetwork @@ -1 +1 @@ -Subproject commit 61ed291c2608f280b8391b41fb46f8ca55a79dbf +Subproject commit 25359b261b10119a24f6bfc2f8486e98fd99e616 diff --git a/coreui/surface_config/surface.json b/coreui/surface_config/surface.json index 31ddf7a..bb63556 100644 --- a/coreui/surface_config/surface.json +++ b/coreui/surface_config/surface.json @@ -1,6 +1,6 @@ { "network": { - "eth_interface": "virbr0", + "eth_interface": "sim:default", "uid": 200 }, diff --git a/io_sim/main.cpp b/io_sim/main.cpp index b605c12..536e785 100644 --- a/io_sim/main.cpp +++ b/io_sim/main.cpp @@ -87,9 +87,13 @@ std::vector gen_packet_strm_from_file(std::string file, int channel int main(int argc, char* argv[]) { std::cout << "OpenAudioLive IO Emulator" << std::endl; + /* + * Param structure : ./io_simulator + * eth_iface may be a transport prefix (sim:default, raw:en0) on host dev. + */ + PeerConf conf{}; - conf.iface = "virbr0"; - //conf.iface = "enx9cbf0d008387"; + conf.iface = (argc > 1) ? argv[1] : "virbr0"; const char name[32] = "IOSIM"; memcpy(&conf.dev_name, name, strlen(name)); diff --git a/tools/sim_switch/CMakeLists.txt b/tools/sim_switch/CMakeLists.txt new file mode 100644 index 0000000..a7b873a --- /dev/null +++ b/tools/sim_switch/CMakeLists.txt @@ -0,0 +1,38 @@ +add_executable(sim_switch + main.cpp + Switch.cpp + DiscoveryPeek.cpp + Tui.cpp +) + +target_compile_features(sim_switch PRIVATE cxx_std_20) + +target_include_directories(sim_switch PRIVATE + ${CMAKE_SOURCE_DIR}/OpenAudioNetwork + ${CMAKE_CURRENT_SOURCE_DIR} +) + +# Smoke test (only when OAN_HOST_BACKENDS so SimTransport is built into oannetutils) +if(OAN_HOST_BACKENDS) + find_package(GTest QUIET) + if(GTest_FOUND) + enable_testing() + add_executable(sim_switch_test test/test_sim_switch.cpp) + target_compile_features(sim_switch_test PRIVATE cxx_std_20) + target_include_directories(sim_switch_test PRIVATE + ${CMAKE_SOURCE_DIR}/OpenAudioNetwork + ${CMAKE_CURRENT_SOURCE_DIR} + ) + target_link_libraries(sim_switch_test PRIVATE + GTest::gtest GTest::gtest_main + oannetutils + ) + # Tests need the daemon binary in PATH; expose its build location. + add_test(NAME sim_switch_smoke COMMAND sim_switch_test) + set_tests_properties(sim_switch_smoke PROPERTIES + ENVIRONMENT "SIM_SWITCH_BIN=$" + ) + else() + message(STATUS "sim_switch: GTest not found, skipping sim_switch_test") + endif() +endif() diff --git a/tools/sim_switch/DiscoveryPeek.cpp b/tools/sim_switch/DiscoveryPeek.cpp new file mode 100644 index 0000000..b5b2cc3 --- /dev/null +++ b/tools/sim_switch/DiscoveryPeek.cpp @@ -0,0 +1,36 @@ +#include "DiscoveryPeek.h" + +#include + +// Layout of a disco frame's payload bytes (what the daemon receives in SimFrame +// payload): [ethhdr 14][LowLatHeader 6][OANPacket]. +// Skip 20 bytes to land on the OANPacket header. +constexpr size_t LL_HEADERS_SIZE = 14 + 6; + +void DiscoveryPeek::observe(const uint8_t* payload, size_t len, uint64_t now_ms) { + if (len < LL_HEADERS_SIZE + sizeof(OANPacket)) return; + + OANPacket opck{}; + std::memcpy(&opck, payload + LL_HEADERS_SIZE, sizeof(opck)); + + if (opck.header.type != PacketType::MAPPING) return; + + auto& d = m_devices[opck.packet_data.self_uid]; + d.uid = opck.packet_data.self_uid; + d.mac = opck.packet_data.self_address; + d.name = std::string(opck.packet_data.dev_name, + strnlen(opck.packet_data.dev_name, 32)); + d.type = opck.packet_data.type; + d.topo = opck.packet_data.topo; + d.last_seen_ms = now_ms; +} + +void DiscoveryPeek::prune(uint64_t now_ms, uint64_t max_age_ms) { + for (auto it = m_devices.begin(); it != m_devices.end(); ) { + if (now_ms - it->second.last_seen_ms > max_age_ms) { + it = m_devices.erase(it); + } else { + ++it; + } + } +} diff --git a/tools/sim_switch/DiscoveryPeek.h b/tools/sim_switch/DiscoveryPeek.h new file mode 100644 index 0000000..d195a2b --- /dev/null +++ b/tools/sim_switch/DiscoveryPeek.h @@ -0,0 +1,31 @@ +#ifndef OSST_SIM_SWITCH_DISCOVERY_PEEK_H +#define OSST_SIM_SWITCH_DISCOVERY_PEEK_H + +#include +#include +#include +#include + +#include "common/packet_structs.h" + +struct DeviceEntry { + uint16_t uid{0}; + uint64_t mac{0}; + std::string name; + DeviceType type{}; + NodeTopology topo{}; + uint64_t last_seen_ms{0}; +}; + +class DiscoveryPeek { +public: + void observe(const uint8_t* payload, size_t len, uint64_t now_ms); + void prune(uint64_t now_ms, uint64_t max_age_ms); + + const std::unordered_map& devices() const { return m_devices; } + +private: + std::unordered_map m_devices; +}; + +#endif diff --git a/tools/sim_switch/Switch.cpp b/tools/sim_switch/Switch.cpp new file mode 100644 index 0000000..92a7bc9 --- /dev/null +++ b/tools/sim_switch/Switch.cpp @@ -0,0 +1,229 @@ +#include "Switch.h" + +#include +#include +#include +#include + +#include +#include +#include + +// 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; + +Switch::EtypeIdx Switch::etype_index(uint16_t e) { + switch (e) { + case ETH_PROTO_OANAUDIO: return EtypeIdx::AUDIO; + case ETH_PROTO_OANDISCO: return EtypeIdx::DISCO; + case ETH_PROTO_OANCONTROL: return EtypeIdx::CONTROL; + case ETH_PROTO_OANSYNC: return EtypeIdx::SYNC; + default: return EtypeIdx::OTHER; + } +} + +Switch::Conn* Switch::find_conn(int fd) { + auto it = m_fd_to_idx.find(fd); + if (it == m_fd_to_idx.end()) return nullptr; + return &m_conns[it->second]; +} + +void Switch::on_accept(int fd) { + Conn c{}; + c.fd = fd; + c.rx_buf.reserve(RX_READ_CHUNK); + m_fd_to_idx[fd] = m_conns.size(); + m_conns.push_back(std::move(c)); +} + +void Switch::on_hangup(int fd) { + remove_conn(fd); +} + +void Switch::remove_conn(int fd) { + auto idx_it = m_fd_to_idx.find(fd); + if (idx_it == m_fd_to_idx.end()) return; + size_t idx = idx_it->second; + Conn& c = m_conns[idx]; + + if (c.hello_received) { + uint32_t key = (uint32_t(static_cast(c.ethertype)) << 16) | c.self_uid; + auto rt = m_route_table.find(key); + if (rt != m_route_table.end() && rt->second == fd) { + m_route_table.erase(rt); + } + } + + ::close(fd); + + // Swap-and-pop so we keep O(1) removal. Fix up the swapped conn's index. + size_t last = m_conns.size() - 1; + if (idx != last) { + m_conns[idx] = std::move(m_conns[last]); + m_fd_to_idx[m_conns[idx].fd] = idx; + } + m_conns.pop_back(); + m_fd_to_idx.erase(idx_it); +} + +bool Switch::on_readable(int fd) { + Conn* c = find_conn(fd); + if (!c) return false; + + uint8_t buf[RX_READ_CHUNK]; + ssize_t n = ::read(fd, buf, sizeof(buf)); + if (n == 0) return false; // peer closed + if (n < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) return true; + return false; + } + + c->rx_buf.insert(c->rx_buf.end(), buf, buf + n); + + // Drain as many complete messages as we have. + while (true) { + if (!c->hello_received) { + int r = consume_hello(*c); + if (r == 0) break; // not enough bytes yet + if (r < 0) return false; // bad magic → caller closes + // r > 0 → hello accepted, loop to parse first frame if buffered + } else { + int r = consume_frame(*c); + if (r == 0) break; // not enough bytes yet + if (r < 0) return false; // oversize → caller closes + // r > 0 → frame delivered, loop to parse the next one if buffered + } + } + + return true; +} + +int Switch::consume_hello(Conn& c) { + if (c.rx_buf.size() < sizeof(SimHello)) return 0; + + SimHello h{}; + std::memcpy(&h, c.rx_buf.data(), sizeof(h)); + + if (h.magic != SIM_MAGIC) { + std::cerr << "sim_switch: bad hello magic on fd=" << c.fd + << " (got 0x" << std::hex << h.magic << std::dec << "); closing\n"; + return -1; + } + if (h.version != SIM_VERSION) { + std::cerr << "sim_switch: hello version mismatch on fd=" << c.fd + << " (got " << (int)h.version << ", expected " << (int)SIM_VERSION + << "); closing\n"; + return -1; + } + + c.ethertype = static_cast(h.ethertype); + c.self_uid = h.self_uid; + c.hello_received = true; + c.rx_buf.erase(c.rx_buf.begin(), c.rx_buf.begin() + sizeof(SimHello)); + + uint32_t key = (uint32_t(h.ethertype) << 16) | h.self_uid; + m_route_table[key] = c.fd; + + return 1; +} + +int Switch::consume_frame(Conn& c) { + if (c.rx_buf.size() < sizeof(SimFrame)) return 0; + + SimFrame hdr{}; + std::memcpy(&hdr, c.rx_buf.data(), sizeof(hdr)); + + if (hdr.payload_len > MAX_FRAME_PAYLOAD) { + std::cerr << "sim_switch: oversize frame from uid=" << c.self_uid + << " len=" << hdr.payload_len << "; dropping connection\n"; + return -1; + } + + if (c.rx_buf.size() < sizeof(SimFrame) + hdr.payload_len) return 0; + + const uint8_t* payload = c.rx_buf.data() + sizeof(SimFrame); + + // Stats + int idx = (int)etype_index(hdr.ethertype); + m_stats.frames_in[idx]++; + m_stats.bytes_in[idx] += hdr.payload_len; + if (hdr.dest_uid == 0) m_stats.bcast_in[idx]++; + + // TUI enrichment (bounded — only disco) + if (hdr.ethertype == ETH_PROTO_OANDISCO) { + m_disco.observe(payload, hdr.payload_len, m_now_ms); + } + + fanout(c, hdr, payload); + + c.rx_buf.erase(c.rx_buf.begin(), + c.rx_buf.begin() + sizeof(SimFrame) + hdr.payload_len); + return 1; +} + +void Switch::fanout(const Conn& sender, const SimFrame& hdr, const uint8_t* payload) { + if (hdr.dest_uid == 0) { + for (auto& target : m_conns) { + if (target.fd == sender.fd) continue; + if (!target.hello_received) continue; + if (target.ethertype != static_cast(hdr.ethertype)) continue; + try_write(target, + reinterpret_cast(&hdr), sizeof(hdr), + payload, hdr.payload_len); + } + } else { + uint32_t key = (uint32_t(hdr.ethertype) << 16) | hdr.dest_uid; + auto it = m_route_table.find(key); + if (it == m_route_table.end()) return; // unknown UID — drop silently + if (it->second == sender.fd) return; + Conn* target = find_conn(it->second); + if (!target) return; + try_write(*target, + reinterpret_cast(&hdr), sizeof(hdr), + payload, hdr.payload_len); + } +} + +void Switch::try_write(Conn& target, const uint8_t* hdr_buf, size_t hdr_len, + const uint8_t* payload, size_t payload_len) { + iovec iov[2] = { + { const_cast(hdr_buf), hdr_len }, + { const_cast(payload), payload_len } + }; + msghdr m{}; + m.msg_iov = iov; + m.msg_iovlen = 2; + + ssize_t n = ::sendmsg(target.fd, &m, MSG_DONTWAIT +#ifdef __linux__ + | MSG_NOSIGNAL +#endif + ); + if (n < 0) { + // Any send error counts as a drop. Hangups surface via poll(POLLHUP) + // separately, so we don't need to remove the conn here. + target.drops++; + return; + } + if (static_cast(n) < hdr_len + payload_len) { + // Partial write under load: simplest policy is to drop the remainder. + target.drops++; + } +} + +void Switch::prune_disco(uint64_t now_ms, uint64_t max_age_ms) { + m_disco.prune(now_ms, max_age_ms); +} + +std::vector Switch::conns() const { + std::vector out; + out.reserve(m_conns.size()); + for (const auto& c : m_conns) { + out.push_back({c.fd, c.hello_received, + static_cast(c.ethertype), + c.self_uid, c.drops}); + } + return out; +} diff --git a/tools/sim_switch/Switch.h b/tools/sim_switch/Switch.h new file mode 100644 index 0000000..d67e05f --- /dev/null +++ b/tools/sim_switch/Switch.h @@ -0,0 +1,85 @@ +#ifndef OSST_SIM_SWITCH_SWITCH_H +#define OSST_SIM_SWITCH_SWITCH_H + +#include +#include +#include +#include + +#include "netutils/LowLatSocket.h" // EthProtocol enum +#include "sim_proto.h" +#include "DiscoveryPeek.h" + +class Switch { +public: + enum class EtypeIdx : int { + AUDIO = 0, + DISCO = 1, + CONTROL = 2, + SYNC = 3, + OTHER = 4, + COUNT = 5 + }; + + struct Stats { + uint64_t frames_in[(int)EtypeIdx::COUNT]{}; + uint64_t bytes_in[(int)EtypeIdx::COUNT]{}; + uint64_t bcast_in[(int)EtypeIdx::COUNT]{}; + }; + + struct ConnSummary { + int fd; + bool hello_received; + uint16_t ethertype; + uint16_t self_uid; + uint64_t drops; + }; + + void on_accept(int fd); + void on_hangup(int fd); + // Returns false if the conn should be closed (bad hello, framing error). + bool on_readable(int fd); + + const Stats& stats() const { return m_stats; } + const DiscoveryPeek& disco() const { return m_disco; } + std::vector conns() const; + + void set_now_ms(uint64_t now_ms) { m_now_ms = now_ms; } + void prune_disco(uint64_t now_ms, uint64_t max_age_ms); + + static EtypeIdx etype_index(uint16_t e); + +private: + struct Conn { + int fd{-1}; + bool hello_received{false}; + bool must_close{false}; + EthProtocol ethertype{}; + uint16_t self_uid{0}; + std::vector rx_buf; + uint64_t drops{0}; + }; + + Conn* find_conn(int fd); + void remove_conn(int fd); + // Returns: 1 = accepted, 0 = need more bytes, -1 = bad → close conn. + int consume_hello(Conn& c); + // Returns: 1 = delivered, 0 = need more bytes, -1 = bad → close conn. + int consume_frame(Conn& c); + void fanout(const Conn& sender, const SimFrame& hdr, const uint8_t* payload); + void try_write(Conn& target, const uint8_t* hdr_buf, size_t hdr_len, + const uint8_t* payload, size_t payload_len); + + std::vector m_conns; + // fd → index into m_conns. Kept in sync with m_conns to give O(1) + // lookup on the hot path (every readable event and unicast fanout). + std::unordered_map m_fd_to_idx; + // key = (ethertype << 16) | dest_uid → conn fd + std::unordered_map m_route_table; + + DiscoveryPeek m_disco; + Stats m_stats; + uint64_t m_now_ms{0}; +}; + +#endif diff --git a/tools/sim_switch/Tui.cpp b/tools/sim_switch/Tui.cpp new file mode 100644 index 0000000..512a2a9 --- /dev/null +++ b/tools/sim_switch/Tui.cpp @@ -0,0 +1,253 @@ +#include "Tui.h" +#include "Switch.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +const char* device_type_name(DeviceType t) { + switch (t) { + case DeviceType::CONTROL_SURFACE: return "CONTROL_SURFACE"; + case DeviceType::MONITORING: return "MONITORING"; + case DeviceType::AUDIO_IO_INTERFACE: return "AUDIO_IO"; + case DeviceType::AUDIO_DSP: return "AUDIO_DSP"; + } + return "?"; +} + +std::string mac_to_string(uint64_t mac_u64) { + uint8_t b[8]; + std::memcpy(b, &mac_u64, 8); + char buf[24]; + std::snprintf(buf, sizeof(buf), "%02x:%02x:%02x:%02x:%02x:%02x", + b[0], b[1], b[2], b[3], b[4], b[5]); + return buf; +} + +const char* etype_label(int idx) { + switch (idx) { + case 0: return "audio "; + case 1: return "disco "; + case 2: return "control"; + case 3: return "sync "; + case 4: return "other "; + } + return "? "; +} + +uint16_t etype_value(int idx) { + switch (idx) { + case 0: return 0x0681; + case 1: return 0x0682; + case 2: return 0x0683; + case 3: return 0x0684; + default: return 0; + } +} + +} // namespace + +namespace { +// Restores cursor visibility on any normal process exit (atexit covers +// std::exit, returning from main, uncaught exception std::terminate paths). +// SIGKILL/SIGSEGV bypass atexit by design — nothing we can do there. +void restore_cursor() { + // Use raw write() to stdout — std::cout may already have been torn down + // by static destructors running before atexit handlers on some toolchains. + const char esc[] = "\033[?25h"; + ssize_t r = ::write(STDOUT_FILENO, esc, sizeof(esc) - 1); + (void)r; +} +} // namespace + +Tui::Tui(std::string socket_path, bool headless) + : m_socket_path(std::move(socket_path)), m_headless(headless) { + // Auto-fall-back to headless when not on a tty (background, pipe, file). + if (!m_headless && !::isatty(STDOUT_FILENO)) m_headless = true; + if (!m_headless) { + std::cout << "\033[?25l" << std::flush; // hide cursor + // Defensive: even if shutdown() never runs (uncaught signal handler, + // std::terminate, etc.), restore the cursor before the process dies. + static bool s_atexit_installed = false; + if (!s_atexit_installed) { + std::atexit(restore_cursor); + s_atexit_installed = true; + } + } +} + +void Tui::shutdown() { + if (!m_headless) { + std::cout << "\033[?25h" << std::flush; // show cursor + } +} + +void Tui::refresh(const Switch& sw, uint64_t now_ms) { + if (m_prev_now_ms == 0) { + m_prev_now_ms = now_ms; + for (int i = 0; i < 5; ++i) { + m_prev_frames[i] = sw.stats().frames_in[i]; + m_prev_bytes[i] = sw.stats().bytes_in[i]; + m_prev_bcast[i] = sw.stats().bcast_in[i]; + } + // First call: don't render rates (no dt yet). Show structure only. + if (!m_headless) render_tui(sw, 1.0); + return; + } + + double dt_s = (now_ms - m_prev_now_ms) / 1000.0; + if (dt_s <= 0) dt_s = 0.001; + + if (m_headless) { + if (now_ms - m_last_headless_ms >= 5000) { + render_headless(sw, dt_s, now_ms); + m_last_headless_ms = now_ms; + // Snapshot for next dt calc: + m_prev_now_ms = now_ms; + for (int i = 0; i < 5; ++i) { + m_prev_frames[i] = sw.stats().frames_in[i]; + m_prev_bytes[i] = sw.stats().bytes_in[i]; + m_prev_bcast[i] = sw.stats().bcast_in[i]; + } + } + } else { + render_tui(sw, dt_s); + m_prev_now_ms = now_ms; + for (int i = 0; i < 5; ++i) { + m_prev_frames[i] = sw.stats().frames_in[i]; + m_prev_bytes[i] = sw.stats().bytes_in[i]; + m_prev_bcast[i] = sw.stats().bcast_in[i]; + } + } +} + +void Tui::erase_previous() { + if (m_lines_rendered <= 0) return; + // Cursor up N lines. + std::cout << "\033[" << m_lines_rendered << "A"; + // Each line: clear and re-print. + // We don't actually erase yet — emit_line does \033[2K\r at each new line. +} + +void Tui::emit_line(const std::string& line) { + // \033[2K = erase entire line; \r = carriage return so we start at col 0. + std::cout << "\033[2K\r" << line << "\n"; +} + +void Tui::render_tui(const Switch& sw, double dt_s) { + erase_previous(); + + std::vector lines; + + auto conns = sw.conns(); + + { + std::ostringstream l; + l << "sim_switch " << m_socket_path + << " conns=" << conns.size(); + lines.push_back(l.str()); + } + lines.push_back(""); + lines.push_back("Traffic (msg/s / KiB/s / bcast%):"); + + for (int i = 0; i < 5; ++i) { + uint64_t df = sw.stats().frames_in[i] - m_prev_frames[i]; + uint64_t db = sw.stats().bytes_in[i] - m_prev_bytes[i]; + uint64_t dc = sw.stats().bcast_in[i] - m_prev_bcast[i]; + + if (i == 4 && df == 0 && sw.stats().frames_in[i] == 0) continue; + + double fps = df / dt_s; + double kbps = (db / dt_s) / 1024.0; + double bcast_pct = df > 0 ? (100.0 * dc / df) : 0.0; + + std::ostringstream l; + l << " " << etype_label(i) + << " 0x" << std::hex << std::setw(4) << std::setfill('0') << etype_value(i) + << std::dec << std::setfill(' ') + << " " << std::fixed << std::setprecision(1) << std::setw(7) << fps << "/s" + << " " << std::fixed << std::setprecision(1) << std::setw(7) << kbps << " KiB/s" + << " " << std::fixed << std::setprecision(0) << std::setw(3) << bcast_pct << "%"; + lines.push_back(l.str()); + } + + lines.push_back(""); + lines.push_back("Devices:"); + if (sw.disco().devices().empty()) { + lines.push_back(" (none discovered yet)"); + } else { + for (const auto& [uid, d] : sw.disco().devices()) { + std::ostringstream l; + l << " uid=" << std::setw(5) << uid + << " mac=" << mac_to_string(d.mac) + << " " << std::left << std::setw(20) << d.name << std::right + << " type=" << std::left << std::setw(16) << device_type_name(d.type) << std::right + << " in=" << (int)d.topo.phy_in_count + << " out=" << (int)d.topo.phy_out_count + << " pipes=" << (int)d.topo.pipes_count; + lines.push_back(l.str()); + } + } + + lines.push_back(""); + lines.push_back("Conns:"); + if (conns.empty()) { + lines.push_back(" (none)"); + } else { + for (const auto& c : conns) { + std::ostringstream l; + l << " fd=" << std::setw(3) << c.fd + << " " << (c.hello_received ? "OK " : "PRE ") + << " etype=0x" << std::hex << std::setw(4) << std::setfill('0') << c.ethertype + << std::dec << std::setfill(' ') + << " uid=" << std::setw(5) << c.self_uid + << " drops=" << c.drops; + lines.push_back(l.str()); + } + } + + int new_lines = static_cast(lines.size()); + for (const auto& l : lines) emit_line(l); + + // If previous render had more lines than this one, blank the extras and + // move the cursor back up over them so they get overwritten next time. + int extra = m_lines_rendered - new_lines; + for (int i = 0; i < extra; ++i) emit_line(""); + if (extra > 0) std::cout << "\033[" << extra << "A"; + + std::cout << std::flush; + m_lines_rendered = new_lines; +} + +void Tui::render_headless(const Switch& sw, double dt_s, uint64_t now_ms) { + char ts[16]; + time_t sec = now_ms / 1000; + struct tm tm_local; + localtime_r(&sec, &tm_local); + std::strftime(ts, sizeof(ts), "%H:%M:%S", &tm_local); + + auto conns = sw.conns(); + uint64_t total_drops = 0; + for (const auto& c : conns) total_drops += c.drops; + + double fps[5]; + for (int i = 0; i < 5; ++i) { + uint64_t df = sw.stats().frames_in[i] - m_prev_frames[i]; + fps[i] = df / dt_s; + } + + std::cout << "[" << ts << "]" + << " conns=" << conns.size() + << " audio=" << std::fixed << std::setprecision(1) << fps[0] << "/s" + << " disco=" << fps[1] << "/s" + << " control=" << fps[2] << "/s" + << " sync=" << fps[3] << "/s" + << " drops=" << total_drops + << "\n" << std::flush; +} diff --git a/tools/sim_switch/Tui.h b/tools/sim_switch/Tui.h new file mode 100644 index 0000000..ea065f1 --- /dev/null +++ b/tools/sim_switch/Tui.h @@ -0,0 +1,44 @@ +#ifndef OSST_SIM_SWITCH_TUI_H +#define OSST_SIM_SWITCH_TUI_H + +#include +#include +#include + +class Switch; + +class Tui { +public: + explicit Tui(std::string socket_path, bool headless); + + // Called at ~5Hz from the main loop. Reads stats from the Switch (samples + // rate over the elapsed wall-time since the last call) and renders. + void refresh(const Switch& sw, uint64_t now_ms); + + // Final cleanup — restore cursor visibility, move to bottom. + void shutdown(); + +private: + void render_tui(const Switch& sw, double dt_s); + void render_headless(const Switch& sw, double dt_s, uint64_t now_ms); + + void erase_previous(); + void emit_line(const std::string& line); + + std::string m_socket_path; + bool m_headless; + + // Sampled at the previous refresh — used to compute per-second rates. + uint64_t m_prev_frames[5]{}; + uint64_t m_prev_bytes[5]{}; + uint64_t m_prev_bcast[5]{}; + uint64_t m_prev_now_ms{0}; + + // For non-flicker redraw — track the previous render's line count. + int m_lines_rendered{0}; + + // For headless mode — emit only every 5s. + uint64_t m_last_headless_ms{0}; +}; + +#endif diff --git a/tools/sim_switch/main.cpp b/tools/sim_switch/main.cpp new file mode 100644 index 0000000..94bd246 --- /dev/null +++ b/tools/sim_switch/main.cpp @@ -0,0 +1,224 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "Switch.h" +#include "Tui.h" + +namespace { + +constexpr const char* DEFAULT_SOCKET_PATH = "/tmp/osst-sim-default.sock"; + +int g_shutdown_pipe[2] = {-1, -1}; +std::atomic g_shutdown_requested{false}; + +void on_signal(int) { + g_shutdown_requested = true; + if (g_shutdown_pipe[1] >= 0) { + char b = 'q'; + ssize_t r = ::write(g_shutdown_pipe[1], &b, 1); + (void)r; + } +} + +void set_nonblock(int fd) { + int flags = ::fcntl(fd, F_GETFL, 0); + ::fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} + +uint64_t now_ms() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); +} + +struct Args { + std::string socket_path = DEFAULT_SOCKET_PATH; + bool headless = false; +}; + +Args parse_args(int argc, char** argv) { + Args a; + for (int i = 1; i < argc; ++i) { + std::string s = argv[i]; + if (s == "--socket-path" && i + 1 < argc) { + a.socket_path = argv[++i]; + } else if (s == "--headless") { + a.headless = true; + } else if (s == "-h" || s == "--help") { + std::cout + << "sim_switch — Unix-socket OAN switch daemon\n" + << "Usage: sim_switch [--socket-path ] [--headless]\n" + << " --socket-path default " << DEFAULT_SOCKET_PATH << "\n" + << " --headless periodic stdout snapshot instead of TUI\n"; + std::exit(0); + } else { + std::cerr << "sim_switch: unknown arg '" << s << "'\n"; + std::exit(2); + } + } + return a; +} + +int open_listen_socket(const std::string& path) { + // Unlink stale socket file. + if (::unlink(path.c_str()) == 0) { + std::cerr << "sim_switch: unlinked stale socket file " << path << "\n"; + } else if (errno != ENOENT) { + std::cerr << "sim_switch: warning: could not unlink " << path + << ": " << ::strerror(errno) << "\n"; + } + + int fd = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { + std::cerr << "sim_switch: socket() failed: " << ::strerror(errno) << "\n"; + return -1; + } + + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + if (path.size() >= sizeof(addr.sun_path)) { + std::cerr << "sim_switch: socket path too long\n"; + ::close(fd); + return -1; + } + std::strncpy(addr.sun_path, path.c_str(), sizeof(addr.sun_path) - 1); + + if (::bind(fd, reinterpret_cast(&addr), sizeof(addr)) < 0) { + std::cerr << "sim_switch: bind() failed: " << ::strerror(errno) << "\n"; + ::close(fd); + return -1; + } + if (::listen(fd, 16) < 0) { + std::cerr << "sim_switch: listen() failed: " << ::strerror(errno) << "\n"; + ::close(fd); + ::unlink(path.c_str()); + return -1; + } + + set_nonblock(fd); + return fd; +} + +} // namespace + +int main(int argc, char** argv) { + Args args = parse_args(argc, argv); + + if (::pipe(g_shutdown_pipe) < 0) { + std::cerr << "sim_switch: pipe() failed\n"; + return 1; + } + set_nonblock(g_shutdown_pipe[0]); + set_nonblock(g_shutdown_pipe[1]); + + struct sigaction sa{}; + sa.sa_handler = on_signal; + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_RESTART; + ::sigaction(SIGINT, &sa, nullptr); + ::sigaction(SIGTERM, &sa, nullptr); + ::signal(SIGPIPE, SIG_IGN); + + int listen_fd = open_listen_socket(args.socket_path); + if (listen_fd < 0) return 1; + + std::cerr << "sim_switch: listening on " << args.socket_path + << (args.headless ? " (headless)" : "") << "\n"; + + Switch sw; + Tui tui(args.socket_path, args.headless); + + std::vector client_fds; + + while (!g_shutdown_requested.load()) { + std::vector pfds; + pfds.push_back({listen_fd, POLLIN, 0}); + pfds.push_back({g_shutdown_pipe[0], POLLIN, 0}); + for (int fd : client_fds) { + pfds.push_back({fd, POLLIN, 0}); + } + + int rc = ::poll(pfds.data(), pfds.size(), 200); + if (rc < 0) { + if (errno == EINTR) continue; + std::cerr << "sim_switch: poll() failed: " << ::strerror(errno) << "\n"; + break; + } + + uint64_t t = now_ms(); + sw.set_now_ms(t); + + if (rc > 0) { + // Accept new + if (pfds[0].revents & POLLIN) { + while (true) { + int cfd = ::accept(listen_fd, nullptr, nullptr); + if (cfd < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) break; + std::cerr << "sim_switch: accept() failed: " + << ::strerror(errno) << "\n"; + break; + } + set_nonblock(cfd); + sw.on_accept(cfd); + client_fds.push_back(cfd); + } + } + + // Shutdown pipe + if (pfds[1].revents & POLLIN) { + char buf[8]; + ssize_t r = ::read(g_shutdown_pipe[0], buf, sizeof(buf)); + (void)r; + break; + } + + // Clients + std::vector to_remove; + for (size_t i = 2; i < pfds.size(); ++i) { + int fd = pfds[i].fd; + short re = pfds[i].revents; + if (re & (POLLERR | POLLHUP | POLLNVAL)) { + to_remove.push_back(fd); + continue; + } + if (re & POLLIN) { + if (!sw.on_readable(fd)) { + to_remove.push_back(fd); + } + } + } + for (int fd : to_remove) { + sw.on_hangup(fd); + client_fds.erase(std::remove(client_fds.begin(), client_fds.end(), fd), + client_fds.end()); + } + } + + // Periodic housekeeping: prune stale disco entries, refresh TUI. + sw.prune_disco(t, 20000); + tui.refresh(sw, t); + } + + std::cerr << "\nsim_switch: shutting down\n"; + tui.shutdown(); + for (int fd : client_fds) ::close(fd); + ::close(listen_fd); + ::unlink(args.socket_path.c_str()); + ::close(g_shutdown_pipe[0]); + ::close(g_shutdown_pipe[1]); + return 0; +} diff --git a/tools/sim_switch/sim_proto.h b/tools/sim_switch/sim_proto.h new file mode 100644 index 0000000..9da6404 --- /dev/null +++ b/tools/sim_switch/sim_proto.h @@ -0,0 +1,32 @@ +#ifndef OSST_SIM_PROTO_H +#define OSST_SIM_PROTO_H + +#include + +// Host-only wire framing between sim_switch (OALS dev tooling) and +// SimTransport (OAN host backend). Length-prefixed binary; host byte order +// (daemon and clients share machine endianness). +// +// NOTE: this is NOT part of the OAN raw-Ethernet wire contract. Embedded +// firmware never builds this; it exists purely so dev hosts (macOS/Linux) +// can run multi-process OALS over AF_UNIX without raw sockets. + +constexpr uint32_t SIM_MAGIC = 0x4F535354; // 'OSST' +constexpr uint8_t SIM_VERSION = 1; + +struct SimHello { + uint32_t magic; + uint8_t version; // SIM_VERSION; bump on framing change + uint8_t _pad; // reserved, must be 0 + uint16_t ethertype; + uint16_t self_uid; + uint16_t _reserved; // reserved, must be 0 +} __attribute__((packed)); + +struct SimFrame { + uint32_t payload_len; + uint16_t ethertype; + uint16_t dest_uid; +} __attribute__((packed)); + +#endif diff --git a/tools/sim_switch/test/test_sim_switch.cpp b/tools/sim_switch/test/test_sim_switch.cpp new file mode 100644 index 0000000..e9f5c21 --- /dev/null +++ b/tools/sim_switch/test/test_sim_switch.cpp @@ -0,0 +1,321 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sim_proto.h" +#include "netutils/transport/SimTransport.h" +#include "netutils/LowLatSocket.h" + +using namespace std::chrono_literals; + +namespace { + +// Returns the daemon binary path from $SIM_SWITCH_BIN, falling back to a +// build-tree-relative default for direct invocation. +std::string daemon_path() { + const char* p = std::getenv("SIM_SWITCH_BIN"); + if (p && *p) return p; + return "./sim_switch"; +} + +std::string make_test_socket_path() { + return "/tmp/osst-sim-test-" + std::to_string(::getpid()) + "-" + + std::to_string(::time(nullptr)) + ".sock"; +} + +// RAII spawn of sim_switch as a child process. Sends SIGTERM on dtor. +// Use `ASSERT_TRUE(d.ready())` in the test body to bail out cleanly on failure. +class DaemonProc { +public: + explicit DaemonProc(const std::string& socket_path) : m_socket_path(socket_path) { + m_pid = ::fork(); + if (m_pid < 0) return; + if (m_pid == 0) { + std::string bin = daemon_path(); + std::vector argv = { + const_cast(bin.c_str()), + const_cast("--headless"), + const_cast("--socket-path"), + const_cast(socket_path.c_str()), + nullptr + }; + ::execv(bin.c_str(), argv.data()); + std::cerr << "execv failed: " << ::strerror(errno) << "\n"; + std::_Exit(127); + } + // Parent: wait for daemon to be ready. + for (int i = 0; i < 200; ++i) { + int fd = ::socket(AF_UNIX, SOCK_STREAM, 0); + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + std::strncpy(addr.sun_path, socket_path.c_str(), sizeof(addr.sun_path)-1); + if (::connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0) { + ::close(fd); + m_ready = true; + return; + } + ::close(fd); + std::this_thread::sleep_for(20ms); + } + } + + ~DaemonProc() { + if (m_pid > 0) { + ::kill(m_pid, SIGTERM); + int st = 0; + ::waitpid(m_pid, &st, 0); + } + ::unlink(m_socket_path.c_str()); + } + + bool ready() const { return m_ready; } + pid_t pid() const { return m_pid; } + +private: + pid_t m_pid{-1}; + std::string m_socket_path; + bool m_ready{false}; +}; + +// Raw AF_UNIX client (no SimTransport wrapping) so we can drive corner cases. +class RawClient { +public: + bool connect(const std::string& path) { + m_fd = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (m_fd < 0) return false; + sockaddr_un a{}; + a.sun_family = AF_UNIX; + std::strncpy(a.sun_path, path.c_str(), sizeof(a.sun_path)-1); + return ::connect(m_fd, reinterpret_cast(&a), sizeof(a)) == 0; + } + ~RawClient() { if (m_fd >= 0) ::close(m_fd); } + + bool send_hello(uint32_t magic, uint16_t etype, uint16_t uid) { + SimHello h{magic, SIM_VERSION, 0, etype, uid, 0}; + return ::send(m_fd, &h, sizeof(h), 0) == (ssize_t)sizeof(h); + } + bool send_frame(uint16_t etype, uint16_t dest_uid, + const std::vector& payload) { + SimFrame f{(uint32_t)payload.size(), etype, dest_uid}; + ::send(m_fd, &f, sizeof(f), 0); + return ::send(m_fd, payload.data(), payload.size(), 0) + == (ssize_t)payload.size(); + } + // Block up to timeout_ms reading one full frame. Returns payload bytes (or empty on timeout/error). + std::vector read_frame(int timeout_ms) { + pollfd p{m_fd, POLLIN, 0}; + int pr = ::poll(&p, 1, timeout_ms); + if (pr <= 0) return {}; + SimFrame hdr{}; + size_t got = 0; + while (got < sizeof(hdr)) { + ssize_t n = ::read(m_fd, reinterpret_cast(&hdr) + got, + sizeof(hdr) - got); + if (n <= 0) return {}; + got += n; + } + std::vector body(hdr.payload_len); + got = 0; + while (got < hdr.payload_len) { + ssize_t n = ::read(m_fd, body.data() + got, hdr.payload_len - got); + if (n <= 0) return {}; + got += n; + } + return body; + } + int fd() const { return m_fd; } + void close_fd() { if (m_fd >= 0) { ::close(m_fd); m_fd = -1; } } + +private: + int m_fd{-1}; +}; + +} // namespace + +// 1. Hello round-trip: daemon accepts hellos from two clients. +TEST(SimSwitch, HelloAccepted) { + auto path = make_test_socket_path(); + DaemonProc d(path); + ASSERT_TRUE(d.ready()); + RawClient a, b; + ASSERT_TRUE(a.connect(path)); + ASSERT_TRUE(b.connect(path)); + EXPECT_TRUE(a.send_hello(SIM_MAGIC, ETH_PROTO_OANAUDIO, 1)); + EXPECT_TRUE(b.send_hello(SIM_MAGIC, ETH_PROTO_OANAUDIO, 2)); + // Daemon should not close us; verify by absence of EOF in 100ms. + pollfd pa{a.fd(), POLLIN, 0}; + EXPECT_EQ(::poll(&pa, 1, 100), 0); +} + +// 2. Broadcast on a given EtherType is delivered to all peers except sender. +TEST(SimSwitch, BroadcastFanout) { + auto path = make_test_socket_path(); + DaemonProc d(path); + ASSERT_TRUE(d.ready()); + RawClient a, b; + ASSERT_TRUE(a.connect(path)); + ASSERT_TRUE(b.connect(path)); + a.send_hello(SIM_MAGIC, ETH_PROTO_OANDISCO, 10); + b.send_hello(SIM_MAGIC, ETH_PROTO_OANDISCO, 11); + std::this_thread::sleep_for(50ms); // let daemon register both + + std::vector payload(100, 0xAB); + ASSERT_TRUE(a.send_frame(ETH_PROTO_OANDISCO, 0, payload)); + + auto got_b = b.read_frame(500); + ASSERT_EQ(got_b.size(), payload.size()); + EXPECT_EQ(got_b, payload); + + // A should NOT receive its own broadcast. + auto got_a = a.read_frame(100); + EXPECT_TRUE(got_a.empty()); +} + +// 3. Unicast to a known UID is delivered only to that peer. +TEST(SimSwitch, UnicastDelivery) { + auto path = make_test_socket_path(); + DaemonProc d(path); + ASSERT_TRUE(d.ready()); + RawClient a, b, c; + ASSERT_TRUE(a.connect(path)); + ASSERT_TRUE(b.connect(path)); + ASSERT_TRUE(c.connect(path)); + a.send_hello(SIM_MAGIC, ETH_PROTO_OANCONTROL, 42); + b.send_hello(SIM_MAGIC, ETH_PROTO_OANCONTROL, 51); + c.send_hello(SIM_MAGIC, ETH_PROTO_OANCONTROL, 99); + std::this_thread::sleep_for(50ms); + + std::vector payload{1, 2, 3, 4, 5}; + ASSERT_TRUE(a.send_frame(ETH_PROTO_OANCONTROL, 51, payload)); + + auto got_b = b.read_frame(500); + ASSERT_EQ(got_b.size(), payload.size()); + EXPECT_EQ(got_b, payload); + + auto got_c = c.read_frame(100); + EXPECT_TRUE(got_c.empty()); + + auto got_a = a.read_frame(100); + EXPECT_TRUE(got_a.empty()); +} + +// 4. Unicast to an unknown UID is silently dropped. +TEST(SimSwitch, UnknownUnicastDropped) { + auto path = make_test_socket_path(); + DaemonProc d(path); + ASSERT_TRUE(d.ready()); + RawClient a, b; + ASSERT_TRUE(a.connect(path)); + ASSERT_TRUE(b.connect(path)); + a.send_hello(SIM_MAGIC, ETH_PROTO_OANCONTROL, 1); + b.send_hello(SIM_MAGIC, ETH_PROTO_OANCONTROL, 2); + std::this_thread::sleep_for(50ms); + + std::vector payload(10, 0xCC); + ASSERT_TRUE(a.send_frame(ETH_PROTO_OANCONTROL, 999, payload)); + + auto got_b = b.read_frame(100); + EXPECT_TRUE(got_b.empty()); +} + +// 5. Hello with bad magic causes the daemon to drop the connection. +TEST(SimSwitch, BadMagicClosesConn) { + auto path = make_test_socket_path(); + DaemonProc d(path); + ASSERT_TRUE(d.ready()); + RawClient a; + ASSERT_TRUE(a.connect(path)); + EXPECT_TRUE(a.send_hello(0xDEADBEEF, ETH_PROTO_OANAUDIO, 1)); + + // Daemon should close our socket within ~300ms. + pollfd p{a.fd(), POLLIN, 0}; + int pr = ::poll(&p, 1, 500); + ASSERT_GT(pr, 0); + char buf; + EXPECT_EQ(::read(a.fd(), &buf, 1), 0); // EOF from server +} + +// 6. Slow client overflows kernel send buffer → daemon increments drops and +// keeps serving fast clients. We don't have programmatic access to the +// daemon's stats so the check is liveness: A's writes keep succeeding and +// a second fast client C still receives traffic. +TEST(SimSwitch, SlowClientDoesNotBlock) { + auto path = make_test_socket_path(); + DaemonProc d(path); + ASSERT_TRUE(d.ready()); + RawClient a, b, c; + ASSERT_TRUE(a.connect(path)); + ASSERT_TRUE(b.connect(path)); + ASSERT_TRUE(c.connect(path)); + a.send_hello(SIM_MAGIC, ETH_PROTO_OANAUDIO, 1); + b.send_hello(SIM_MAGIC, ETH_PROTO_OANAUDIO, 2); + c.send_hello(SIM_MAGIC, ETH_PROTO_OANAUDIO, 3); + std::this_thread::sleep_for(50ms); + + // B never reads. A blasts a large number of broadcast frames. + std::vector payload(1024, 0xEE); + for (int i = 0; i < 200; ++i) { + ASSERT_TRUE(a.send_frame(ETH_PROTO_OANAUDIO, 0, payload)); + } + + // C should still be able to receive at least some frames — daemon didn't lock up. + int got = 0; + for (int i = 0; i < 10; ++i) { + auto f = c.read_frame(200); + if (!f.empty()) got++; + } + EXPECT_GT(got, 0); +} + +// 7. SimTransport (real impl from OAN) interoperates with the daemon. +// Two SimTransport clients on the same EtherType broadcast → each sees the other. +TEST(SimSwitch, SimTransportInterop) { + auto path = make_test_socket_path(); + // Daemon's default-socket-path is hardcoded; we must use the matching + // SimTransport daemon name. Compose: socket_path = /tmp/osst-sim-.sock. + // Extract from the temp path. + // Easier: use a fixed name and let DaemonProc translate it. + std::string daemon_name = "interop-" + std::to_string(::getpid()); + std::string sock = "/tmp/osst-sim-" + daemon_name + ".sock"; + DaemonProc d(sock); + ASSERT_TRUE(d.ready()); + + SimTransport tA(daemon_name); + SimTransport tB(daemon_name); + + IfaceMeta metaA{}, metaB{}; + ASSERT_TRUE(tA.open("sim:" + daemon_name, ETH_PROTO_OANAUDIO, 7, metaA)); + ASSERT_TRUE(tB.open("sim:" + daemon_name, ETH_PROTO_OANAUDIO, 8, metaB)); + std::this_thread::sleep_for(50ms); + + // tA broadcasts a payload; tB should receive it. + std::vector payload(64, 0x77); + int sent = tA.send(payload.data(), payload.size(), 0); + EXPECT_EQ(sent, (int)payload.size()); + + std::vector buf(payload.size()); + // Loop briefly for delivery (non-blocking recv may need a couple polls). + int got = 0; + for (int i = 0; i < 50; ++i) { + got = tB.recv(buf.data(), buf.size(), true); + if (got > 0) break; + std::this_thread::sleep_for(10ms); + } + EXPECT_EQ(got, (int)payload.size()); + EXPECT_EQ(buf, payload); +} From 64a4bd7d91728fe9c83dc97d6648fe0dd1b0fe8a Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Wed, 3 Jun 2026 19:15:13 +0200 Subject: [PATCH 05/15] io_sim: load tracks from JSON config + bump OAN submodule io_sim's wav paths were hardcoded to a developer's home dir, so it exited on every other machine. Replace with a JSON config (argv[2], default ./io_sim.json) that lists tracks as either {channel, path} or {channel, tone: {freq, gain}}. - nlohmann/json pulled in via FetchContent (header-only, dev-only tool so the dep is acceptable; coreui still uses QJsonDocument). - Tilde-expand paths so ~/Music/... works directly. - Reject non-96k wavs with the exact ffmpeg command needed to convert. - Tone-gen tracks for "is the wire alive?" testing without wavs. - Loops normalized to shortest stream so the cursor wraps cleanly. - io_sim.example.json: 4-channel tone demo (440/880/1k/1.5k Hz). Submodule bump pulls in the NetworkMapper disco/age-loop throttle on host backends. --- OpenAudioNetwork | 2 +- io_sim/CMakeLists.txt | 13 +++- io_sim/io_sim.example.json | 9 +++ io_sim/main.cpp | 129 +++++++++++++++++++++++++++++++------ 4 files changed, 132 insertions(+), 21 deletions(-) create mode 100644 io_sim/io_sim.example.json diff --git a/OpenAudioNetwork b/OpenAudioNetwork index 25359b2..0878f87 160000 --- a/OpenAudioNetwork +++ b/OpenAudioNetwork @@ -1 +1 @@ -Subproject commit 25359b261b10119a24f6bfc2f8486e98fd99e616 +Subproject commit 0878f871bf527549e0afb64c95ec4a6a50907881 diff --git a/io_sim/CMakeLists.txt b/io_sim/CMakeLists.txt index 052a4bb..934e44d 100644 --- a/io_sim/CMakeLists.txt +++ b/io_sim/CMakeLists.txt @@ -5,7 +5,18 @@ add_executable(io_simulator find_package(PkgConfig REQUIRED) pkg_check_modules(SNDFILE REQUIRED sndfile) +include(FetchContent) +FetchContent_Declare( + nlohmann_json + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG v3.11.3 + GIT_SHALLOW TRUE +) +set(JSON_BuildTests OFF CACHE INTERNAL "") +set(JSON_Install OFF CACHE INTERNAL "") +FetchContent_MakeAvailable(nlohmann_json) + target_include_directories(io_simulator PUBLIC ${PROJECT_SOURCE_DIR} ${SNDFILE_INCLUDE_DIRS}) target_link_directories(io_simulator PUBLIC ${SNDFILE_LIBRARY_DIRS}) -target_link_libraries(io_simulator oancommon oannetutils) +target_link_libraries(io_simulator oancommon oannetutils nlohmann_json::nlohmann_json) target_link_libraries(io_simulator ${SNDFILE_LIBRARIES}) \ No newline at end of file diff --git a/io_sim/io_sim.example.json b/io_sim/io_sim.example.json new file mode 100644 index 0000000..8a1077a --- /dev/null +++ b/io_sim/io_sim.example.json @@ -0,0 +1,9 @@ +{ + "uid": 1, + "tracks": [ + { "channel": 0, "tone": { "freq": 440.0, "gain": 0.3 } }, + { "channel": 1, "tone": { "freq": 880.0, "gain": 0.3 } }, + { "channel": 2, "tone": { "freq": 1000.0, "gain": 0.2 } }, + { "channel": 3, "tone": { "freq": 1500.0, "gain": 0.2 } } + ] +} diff --git a/io_sim/main.cpp b/io_sim/main.cpp index 536e785..40c1956 100644 --- a/io_sim/main.cpp +++ b/io_sim/main.cpp @@ -4,12 +4,17 @@ // This project is distributed under the Creative Commons CC-BY-NC-SA licence. https://creativecommons.org/licenses/by-nc-sa/4.0 #include +#include #include #include #include #include +#include +#include +#include #include +#include #include #include @@ -17,6 +22,15 @@ #include +static constexpr int IO_SIM_SAMPLE_RATE = 96000; + +std::string expand_tilde(const std::string& p) { + if (p.empty() || p[0] != '~') return p; + const char* home = std::getenv("HOME"); + if (!home) return p; + return std::string(home) + p.substr(1); +} + float sig_gen(float f, float gain, int n) { constexpr float T = 1.0f / 96000.0f; float pulse = 2.0f * 3.141592 * f; @@ -56,7 +70,16 @@ std::vector gen_packet_strm_from_file(std::string file, int channel SF_INFO info{}; SNDFILE* wavfile = sf_open(file.c_str(), SFM_READ, &info); if (!wavfile) { - std::cerr << "Failed to read " << file << std::endl; + std::cerr << "io_sim: failed to open " << file << std::endl; + return {}; + } + + if (info.samplerate != IO_SIM_SAMPLE_RATE) { + std::cerr << "io_sim: " << file << " is " << info.samplerate + << " Hz, io_sim runs at " << IO_SIM_SAMPLE_RATE << " Hz.\n" + << " Convert with: ffmpeg -i \"" << file + << "\" -ar " << IO_SIM_SAMPLE_RATE << " \"" << file << ".96k.wav\"\n"; + sf_close(wavfile); return {}; } @@ -84,14 +107,59 @@ std::vector gen_packet_strm_from_file(std::string file, int channel return stream_packets; } +// Build a one-second tone loop as a packet stream. Used for "tone" tracks in +// the config — handy when you don't have a wav handy but want something audible. +std::vector gen_packet_strm_tone(float freq_hz, float gain, int channel) { + constexpr int LOOP_SAMPLES = IO_SIM_SAMPLE_RATE; + const int n_packets = LOOP_SAMPLES / AUDIO_DATA_SAMPLES_PER_PACKETS; + + std::vector stream_packets; + stream_packets.reserve(n_packets); + + const float two_pi_f_over_sr = 2.0f * 3.14159265358979f * freq_hz / (float)IO_SIM_SAMPLE_RATE; + + int n = 0; + for (int p = 0; p < n_packets; ++p) { + AudioPacket pkt{}; + pkt.header.type = PacketType::AUDIO; + pkt.packet_data.channel = channel; + for (int i = 0; i < AUDIO_DATA_SAMPLES_PER_PACKETS; ++i) { + pkt.packet_data.samples[i] = std::sin(two_pi_f_over_sr * n) * gain; + n++; + } + stream_packets.push_back(pkt); + } + return stream_packets; +} + int main(int argc, char* argv[]) { std::cout << "OpenAudioLive IO Emulator" << std::endl; /* - * Param structure : ./io_simulator + * Param structure : ./io_simulator [config_path] * eth_iface may be a transport prefix (sim:default, raw:en0) on host dev. + * config_path defaults to ./io_sim.json. */ + const std::string config_path = (argc > 2) ? argv[2] : "io_sim.json"; + + nlohmann::json cfg; + { + std::ifstream f(config_path); + if (!f) { + std::cerr << "io_sim: failed to open config '" << config_path + << "'. Pass a path as argv[2] or place io_sim.json in cwd.\n"; + return -1; + } + try { + f >> cfg; + } catch (const std::exception& e) { + std::cerr << "io_sim: failed to parse '" << config_path << "': " + << e.what() << std::endl; + return -1; + } + } + PeerConf conf{}; conf.iface = (argc > 1) ? argv[1] : "virbr0"; @@ -100,7 +168,7 @@ int main(int argc, char* argv[]) { conf.sample_rate = SamplingRate::SAMPLING_96K; conf.dev_type = DeviceType::AUDIO_IO_INTERFACE; - conf.uid = 1; + conf.uid = cfg.value("uid", 1); conf.topo.phy_in_count = 4; conf.topo.phy_out_count = 4; conf.topo.pipes_count = 1; @@ -142,23 +210,46 @@ int main(int argc, char* argv[]) { int stream_cursor = 0; - std::vector paths = { - "/home/mathis/osst/audio_test/enc96/Boucle_GC_12.wav", - "/home/mathis/osst/audio_test/enc96/Boucle_CC_12.wav", - "/home/mathis/osst/audio_test/enc96/Boucle_OHL_12.wav", - "/home/mathis/osst/audio_test/enc96/Boucle_OHR_12.wav", - "/home/mathis/osst/audio_test/enc96/Boucle_Perc_12.wav", - "/home/mathis/osst/audio_test/enc96/Boucle_Solo_12.wav", - "/home/mathis/osst/audio_test/enc96/Boucle_Bois_12 L.wav", - "/home/mathis/osst/audio_test/enc96/Boucle_Bois_12 R.wav", - - }; + if (!cfg.contains("tracks") || !cfg["tracks"].is_array() || cfg["tracks"].empty()) { + std::cerr << "io_sim: config '" << config_path + << "' has no 'tracks' array (or it's empty)." << std::endl; + return -1; + } + std::vector> stems_loop; + stems_loop.reserve(cfg["tracks"].size()); + + for (const auto& t : cfg["tracks"]) { + if (!t.contains("channel")) { + std::cerr << "io_sim: track entry missing 'channel'" << std::endl; + return -1; + } + int chann = t["channel"].get(); + + std::vector stream; + if (t.contains("path")) { + std::string p = expand_tilde(t["path"].get()); + stream = gen_packet_strm_from_file(p, chann); + if (stream.empty()) return -1; // gen_packet_strm_from_file already logged + } else if (t.contains("tone")) { + const auto& tone = t["tone"]; + float freq = tone.value("freq", 1000.0f); + float gain = tone.value("gain", 0.3f); + stream = gen_packet_strm_tone(freq, gain, chann); + } else { + std::cerr << "io_sim: track for channel " << chann + << " needs either 'path' or 'tone'" << std::endl; + return -1; + } + stems_loop.push_back(std::move(stream)); + } - int chann = 0; - for (auto& p : paths) { - stems_loop.emplace_back(gen_packet_strm_from_file(p, chann)); - chann++; + // All loops are normalized to the shortest stream so the cursor wraps cleanly. + size_t min_len = stems_loop.front().size(); + for (const auto& s : stems_loop) min_len = std::min(min_len, s.size()); + if (min_len == 0) { + std::cerr << "io_sim: a track produced zero packets" << std::endl; + return -1; } oals::rt::set_thread_realtime(50); @@ -175,7 +266,7 @@ int main(int argc, char* argv[]) { audio_iface.send_data(loop[stream_cursor], 100); } - stream_cursor = (stream_cursor + 1) % stems_loop[0].size(); + stream_cursor = (stream_cursor + 1) % min_len; auto sent = local_now_ns(); ts.tv_nsec -= (sent - start); From cf2ef7c3e22d79e3a13d5b1f26d4b72adbbbc993 Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Wed, 3 Jun 2026 21:31:46 +0200 Subject: [PATCH 06/15] Add oaninspect + switch TUI overhaul (M5 of dev-tooling plan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sim_switch: - SimFrame v2: switch populates src_uid, so observers see who sent. - SimHello v2: flags field with SIM_HELLO_PROMISCUOUS for inspectors that want every fanout regardless of ethertype / dst_uid. - Per-peer tx/rx stat counters per ethertype, attributed during consume_frame and try_write. Pruned alongside disco entries. - TUI rates smoothed via EWMA (α=0.15, ~1s window) so disco's bursty 5-sec interval no longer makes the dashboard oscillate between 20/s and 0/s. - New Peers table sorted by uid with A/D/C/S tx/rx columns — actual data-flow visibility for installations with more than 2 nodes. - "Inspectors: N attached" line. oaninspect (new tool): - Connects as a promiscuous client, ANSI-coloured per-EtherType pretty-print of every frame. - Filter expression with key=value[,value,...] grammar (ethertype/ src/dst/peer/uid). Default suppresses audio. - Audio fast-path: when filter excludes audio, skip at ingest (4000 frames/s) so the 100k ring holds plenty of disco/sync/ control history — audio still counted for the suppression hint. - Pause stops ring ingest (with pending counter), so the frozen view stays frozen even at high wire rates. Filter changes re-filter the existing ring so they work while paused. - Diff-render: only redraws rows whose content changed, no full-screen clear flicker. - Interactive: Space pause, j/k scroll, Ctrl-D/U half-page, g/G top/end, / re-filter, h hex, q quit. Cheat-sheet footer. - Non-TTY fallback: prints plain decoded lines to stdout for CI. - --pcap record / --replay (own binary format). Tests: - +7 switch scenarios (promiscuous receives broadcasts + unicasts to others, src_uid populated by switch, multi- inspector, no loopback, promiscuous not in route table, v1 hello rejected). - 6 Filter parser unit tests. - 3 EWMA unit tests. - 23/23 passing. OAN submodule bump pulls in the matching SimFrame v2 / SimHello v2 on the engine + io_sim + UI side. --- CMakeLists.txt | 3 +- OpenAudioNetwork | 2 +- tools/oaninspect/CMakeLists.txt | 12 + tools/oaninspect/Decoder.cpp | 315 ++++++++ tools/oaninspect/Decoder.h | 31 + tools/oaninspect/Filter.cpp | 107 +++ tools/oaninspect/Filter.h | 45 ++ tools/oaninspect/main.cpp | 831 ++++++++++++++++++++++ tools/sim_switch/CMakeLists.txt | 6 +- tools/sim_switch/Switch.cpp | 85 ++- tools/sim_switch/Switch.h | 18 +- tools/sim_switch/Tui.cpp | 130 +++- tools/sim_switch/Tui.h | 30 + tools/sim_switch/main.cpp | 4 +- tools/sim_switch/sim_proto.h | 12 +- tools/sim_switch/test/test_sim_switch.cpp | 318 ++++++++- 16 files changed, 1898 insertions(+), 51 deletions(-) create mode 100644 tools/oaninspect/CMakeLists.txt create mode 100644 tools/oaninspect/Decoder.cpp create mode 100644 tools/oaninspect/Decoder.h create mode 100644 tools/oaninspect/Filter.cpp create mode 100644 tools/oaninspect/Filter.h create mode 100644 tools/oaninspect/main.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7b921be..dc56792 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,4 +36,5 @@ add_subdirectory(coreui) add_subdirectory(engine) add_subdirectory(io_sim) add_subdirectory(debugger) -add_subdirectory(tools/sim_switch) \ No newline at end of file +add_subdirectory(tools/sim_switch) +add_subdirectory(tools/oaninspect) \ No newline at end of file diff --git a/OpenAudioNetwork b/OpenAudioNetwork index 0878f87..5c74aa9 160000 --- a/OpenAudioNetwork +++ b/OpenAudioNetwork @@ -1 +1 @@ -Subproject commit 0878f871bf527549e0afb64c95ec4a6a50907881 +Subproject commit 5c74aa919a390446d8e67ae3b065fec7629b692d diff --git a/tools/oaninspect/CMakeLists.txt b/tools/oaninspect/CMakeLists.txt new file mode 100644 index 0000000..f327bbc --- /dev/null +++ b/tools/oaninspect/CMakeLists.txt @@ -0,0 +1,12 @@ +add_executable(oaninspect + main.cpp + Filter.cpp + Decoder.cpp +) + +target_compile_features(oaninspect PRIVATE cxx_std_20) + +target_include_directories(oaninspect PRIVATE + ${CMAKE_SOURCE_DIR}/OpenAudioNetwork + ${CMAKE_SOURCE_DIR}/tools/sim_switch # for sim_proto.h +) diff --git a/tools/oaninspect/Decoder.cpp b/tools/oaninspect/Decoder.cpp new file mode 100644 index 0000000..af8bc84 --- /dev/null +++ b/tools/oaninspect/Decoder.cpp @@ -0,0 +1,315 @@ +#include "Decoder.h" + +#include +#include +#include +#include +#include +#include + +#include "common/packet_structs.h" +#include "netutils/LowLatSocket.h" + +namespace { + +// Same layout dance as sim_switch's DiscoveryPeek: the SimFrame payload +// starts at the ethhdr the engine emits, then the LowLatHeader, then +// the OANPacket. +constexpr size_t LL_HEADERS_SIZE = 14 + 6; // ethhdr + LowLatHeader + +// ANSI colour helpers — guarded by DecodeOpts::color. +struct Color { + const char* on; + const char* off; +}; +constexpr Color C_AUDIO = {"\033[36m", "\033[0m"}; // cyan +constexpr Color C_DISCO = {"\033[32m", "\033[0m"}; // green +constexpr Color C_CONTROL = {"\033[33m", "\033[0m"}; // yellow +constexpr Color C_SYNC = {"\033[35m", "\033[0m"}; // magenta +constexpr Color C_UNKNOWN = {"\033[31m", "\033[0m"}; // red + +Color color_for_etype(uint8_t idx) { + switch (idx) { + case 0: return C_AUDIO; + case 1: return C_DISCO; + case 2: return C_CONTROL; + case 3: return C_SYNC; + default: return C_UNKNOWN; + } +} + +const char* device_type_name(DeviceType t) { + switch (t) { + case DeviceType::CONTROL_SURFACE: return "CONTROL_SURFACE"; + case DeviceType::MONITORING: return "MONITORING"; + case DeviceType::AUDIO_IO_INTERFACE: return "AUDIO_IO"; + case DeviceType::AUDIO_DSP: return "AUDIO_DSP"; + } + return "?"; +} + +const char* control_query_name(ControlQueryType q) { + switch (q) { + case ControlQueryType::PHY_OUT_MAP: return "PHY_OUT_MAP"; + case ControlQueryType::PIPES_MAP: return "PIPES_MAP"; + case ControlQueryType::PIPE_ALLOC_RESET: return "PIPE_ALLOC_RESET"; + } + return "?"; +} + +const char* packet_type_name(PacketType t) { + switch (t) { + case PacketType::MAPPING: return "MAPPING"; + case PacketType::CONTROL: return "CONTROL"; + case PacketType::CONTROL_CREATE: return "CTRL_CREATE"; + case PacketType::CONTROL_RESPONSE: return "CTRL_RESPONSE"; + case PacketType::CONTROL_QUERY: return "CTRL_QUERY"; + case PacketType::AUDIO: return "AUDIO"; + case PacketType::CLOCK_SYNC: return "CLOCK_SYNC"; + } + return "?"; +} + +std::string format_timestamp(uint64_t now_ms) { + char buf[16]; + time_t sec = now_ms / 1000; + int ms = static_cast(now_ms % 1000); + struct tm tm_local; + localtime_r(&sec, &tm_local); + char hh[16]; + std::strftime(hh, sizeof(hh), "%H:%M:%S", &tm_local); + std::snprintf(buf, sizeof(buf), "%s.%03d", hh, ms); + return buf; +} + +std::string hex_dump(const uint8_t* p, size_t n, size_t max_bytes) { + std::ostringstream o; + size_t lim = n < max_bytes ? n : max_bytes; + o << " ["; + for (size_t i = 0; i < lim; ++i) { + if (i) o << ' '; + char b[4]; + std::snprintf(b, sizeof(b), "%02x", p[i]); + o << b; + } + if (n > lim) o << " ...(+" << (n - lim) << "B)"; + o << "]"; + return o.str(); +} + +std::string mac_to_string(uint64_t mac_u64) { + uint8_t b[8]; + std::memcpy(b, &mac_u64, 8); + char buf[24]; + std::snprintf(buf, sizeof(buf), "%02x:%02x:%02x:%02x:%02x:%02x", + b[0], b[1], b[2], b[3], b[4], b[5]); + return buf; +} + +// Pretty-print the OAN payload AFTER the ethhdr+LowLatHeader (20 bytes). +// Returns the body text; the caller wraps with the standard envelope. + +std::string decode_audio(const uint8_t* oan, size_t oan_len) { + if (oan_len < sizeof(AudioPacket)) { + std::ostringstream o; + o << "(truncated audio: " << oan_len << "B)"; + return o.str(); + } + AudioPacket p{}; + std::memcpy(&p, oan, sizeof(p)); + + float mn = 1e9f, mx = -1e9f, sumsq = 0.0f; + for (int i = 0; i < AUDIO_DATA_SAMPLES_PER_PACKETS; ++i) { + float s = p.packet_data.samples[i]; + if (s < mn) mn = s; + if (s > mx) mx = s; + sumsq += s * s; + } + float rms = std::sqrt(sumsq / AUDIO_DATA_SAMPLES_PER_PACKETS); + + std::ostringstream o; + o << "ch=" << std::dec << (int)p.packet_data.channel + << " ts=" << p.header.timestamp + << " " << AUDIO_DATA_SAMPLES_PER_PACKETS << "smp" + << " min=" << mn << " max=" << mx << " rms=" << rms; + return o.str(); +} + +std::string decode_disco(const uint8_t* oan, size_t oan_len) { + if (oan_len < sizeof(MappingPacket)) { + std::ostringstream o; + o << "(truncated disco: " << oan_len << "B)"; + return o.str(); + } + MappingPacket p{}; + std::memcpy(&p, oan, sizeof(p)); + + if (p.header.type != PacketType::MAPPING) { + std::ostringstream o; + o << "type=" << packet_type_name(p.header.type) << " (not MAPPING)"; + return o.str(); + } + + std::string name(p.packet_data.dev_name, + strnlen(p.packet_data.dev_name, 32)); + std::ostringstream o; + o << "uid=" << p.packet_data.self_uid + << " mac=" << mac_to_string(p.packet_data.self_address) + << " name=\"" << name << "\"" + << " type=" << device_type_name(p.packet_data.type) + << " in=" << (int)p.packet_data.topo.phy_in_count + << " out=" << (int)p.packet_data.topo.phy_out_count + << " pipes=" << (int)p.packet_data.topo.pipes_count; + return o.str(); +} + +std::string decode_control(const uint8_t* oan, size_t oan_len) { + // Control comes in multiple flavours — switch on the common header's type. + if (oan_len < sizeof(CommonHeader)) { + std::ostringstream o; + o << "(truncated control: " << oan_len << "B)"; + return o.str(); + } + CommonHeader hdr{}; + std::memcpy(&hdr, oan, sizeof(hdr)); + + std::ostringstream o; + o << "type=" << packet_type_name(hdr.type); + + switch (hdr.type) { + case PacketType::CONTROL: { + if (oan_len < sizeof(ControlPacket)) { o << " (truncated)"; break; } + ControlPacket p{}; + std::memcpy(&p, oan, sizeof(p)); + o << " ch=" << (int)p.packet_data.channel + << " elem=" << (int)p.packet_data.elem_index + << " ctrl=" << p.packet_data.control_id; + break; + } + case PacketType::CONTROL_CREATE: { + if (oan_len < sizeof(ControlPipeCreatePacket)) { o << " (truncated)"; break; } + ControlPipeCreatePacket p{}; + std::memcpy(&p, oan, sizeof(p)); + std::string elem(p.packet_data.elem_type, + strnlen(p.packet_data.elem_type, 32)); + o << " ch=" << (int)p.packet_data.channel + << " pid=" << p.packet_data.pid + << " seq=" << (int)p.packet_data.seq << "/" << (int)p.packet_data.seq_max + << " elem=\"" << elem << "\""; + break; + } + case PacketType::CONTROL_RESPONSE: { + if (oan_len < sizeof(ControlResponsePacket)) { o << " (truncated)"; break; } + ControlResponsePacket p{}; + std::memcpy(&p, oan, sizeof(p)); + o << " ch=" << (int)p.packet_data.channel + << " pid=" << p.packet_data.pid + << " code=" << (int)p.packet_data.response; + break; + } + case PacketType::CONTROL_QUERY: { + if (oan_len < sizeof(ControlQueryPacket)) { o << " (truncated)"; break; } + ControlQueryPacket p{}; + std::memcpy(&p, oan, sizeof(p)); + o << " qtype=" << control_query_name(p.packet_data.qtype) + << " flags=0x" << std::hex << p.packet_data.flags << std::dec; + break; + } + default: + o << " (unexpected on control ethertype)"; + break; + } + return o.str(); +} + +std::string decode_sync(const uint8_t* oan, size_t oan_len) { + if (oan_len < sizeof(ClockSyncPacket)) { + std::ostringstream o; + o << "(truncated sync: " << oan_len << "B)"; + return o.str(); + } + ClockSyncPacket p{}; + std::memcpy(&p, oan, sizeof(p)); + std::ostringstream o; + o << "ts=" << p.header.timestamp + << " state=" << (int)p.packet_data.packet_state; + return o.str(); +} + +} // namespace + +uint8_t etype_index_of(uint16_t e) { + switch (e) { + case ETH_PROTO_OANAUDIO: return 0; + case ETH_PROTO_OANDISCO: return 1; + case ETH_PROTO_OANCONTROL: return 2; + case ETH_PROTO_OANSYNC: return 3; + default: return 4; + } +} + +std::string decode_frame_line(uint16_t ethertype, + uint16_t src_uid, + uint16_t dst_uid, + const uint8_t* payload, + size_t payload_len, + uint64_t now_ms, + const DecodeOpts& opts) { + uint8_t idx = etype_index_of(ethertype); + Color c = color_for_etype(idx); + + std::ostringstream o; + if (opts.color) o << c.on; + + o << format_timestamp(now_ms); + + const char* label = "?? "; + switch (idx) { + case 0: label = "audio "; break; + case 1: label = "disco "; break; + case 2: label = "control"; break; + case 3: label = "sync "; break; + default: { + // Unknown ethertype — render hex code in label slot. + char buf[12]; + std::snprintf(buf, sizeof(buf), "0x%04x ", ethertype); + label = buf; + break; + } + } + o << " " << label; + + char dst_buf[16]; + std::snprintf(dst_buf, sizeof(dst_buf), "%5u", dst_uid); + char src_buf[16]; + std::snprintf(src_buf, sizeof(src_buf), "%5u", src_uid); + o << " src=" << src_buf << " dst=" << dst_buf; + + // Body — depends on ethertype. Skip ethhdr + LowLatHeader on payload. + const uint8_t* oan = nullptr; + size_t oan_len = 0; + if (payload_len >= LL_HEADERS_SIZE) { + oan = payload + LL_HEADERS_SIZE; + oan_len = payload_len - LL_HEADERS_SIZE; + } + + o << " "; + if (!oan) { + o << "(short payload " << payload_len << "B)"; + } else { + switch (idx) { + case 0: o << decode_audio(oan, oan_len); break; + case 1: o << decode_disco(oan, oan_len); break; + case 2: o << decode_control(oan, oan_len); break; + case 3: o << decode_sync(oan, oan_len); break; + default: o << "payload=" << payload_len << "B"; break; + } + } + + if (opts.hex && payload) { + o << hex_dump(payload, payload_len, 48); + } + + if (opts.color) o << c.off; + return o.str(); +} diff --git a/tools/oaninspect/Decoder.h b/tools/oaninspect/Decoder.h new file mode 100644 index 0000000..1539b63 --- /dev/null +++ b/tools/oaninspect/Decoder.h @@ -0,0 +1,31 @@ +#ifndef OSST_OANINSPECT_DECODER_H +#define OSST_OANINSPECT_DECODER_H + +#include +#include +#include + +// Options controlling decoder output. Owned by the caller; the decoder +// re-reads each call so toggling at runtime (e.g. 'h' for hex) takes +// effect immediately. +struct DecodeOpts { + bool hex{false}; + bool color{true}; +}; + +// Returns a single rendered line for the frame. Caller appends a newline. +// payload is the bytes the switch fanned out (ethhdr + LowLatHeader + OAN +// payload). Length checks are bounded; truncated frames render as best-effort. +std::string decode_frame_line(uint16_t ethertype, + uint16_t src_uid, + uint16_t dst_uid, + const uint8_t* payload, + size_t payload_len, + uint64_t now_ms, + const DecodeOpts& opts); + +// Index used by the inspector for filter/colour selection. Mirrors +// sim_switch's Switch::EtypeIdx. +uint8_t etype_index_of(uint16_t ethertype); + +#endif diff --git a/tools/oaninspect/Filter.cpp b/tools/oaninspect/Filter.cpp new file mode 100644 index 0000000..4191509 --- /dev/null +++ b/tools/oaninspect/Filter.cpp @@ -0,0 +1,107 @@ +#include "Filter.h" + +#include + +namespace { + +// EtypeIdx must mirror Switch::EtypeIdx so the wire-side mask agrees with +// the decoder. Duplicated here so oaninspect doesn't need to pull in +// sim_switch's Switch.h. +constexpr uint8_t IDX_AUDIO = 0; +constexpr uint8_t IDX_DISCO = 1; +constexpr uint8_t IDX_CONTROL = 2; +constexpr uint8_t IDX_SYNC = 3; +constexpr uint8_t IDX_OTHER = 4; + +int ethertype_name_to_idx(const std::string& n) { + if (n == "audio") return IDX_AUDIO; + if (n == "disco") return IDX_DISCO; + if (n == "control") return IDX_CONTROL; + if (n == "sync") return IDX_SYNC; + if (n == "other") return IDX_OTHER; + return -1; +} + +bool parse_uid(const std::string& s, uint16_t& out, std::string& err) { + if (s.empty()) { err = "empty uid value"; return false; } + char* endp = nullptr; + long v = std::strtol(s.c_str(), &endp, 0); + if (!endp || *endp != '\0' || v < 0 || v > 0xFFFF) { + err = "bad uid '" + s + "'"; + return false; + } + out = static_cast(v); + return true; +} + +} // namespace + +bool Filter::match(uint8_t etype_idx, uint16_t src_uid, uint16_t dst_uid) const { + if (ethertype_mask != 0 && (ethertype_mask & (1u << etype_idx)) == 0) { + return false; + } + if (!src_uids.empty() && !src_uids.count(src_uid)) return false; + if (!dst_uids.empty() && !dst_uids.count(dst_uid)) return false; + if (!peer_uids.empty() + && !peer_uids.count(src_uid) + && !peer_uids.count(dst_uid)) return false; + return true; +} + +bool Filter::parse(const std::string& expr, std::string& err) { + *this = {}; + if (expr.empty()) return true; + + // Split into top-level clauses. The grammar mixes commas at two levels: + // commas inside a value list ARE separators, but they only mean "next + // alternative for the same key". We treat the whole input as a stream + // of key=value tokens where the key sticks until a new key appears + // (i.e. a "k=" prefix). That lets `ethertype=audio,disco,src=42` + // parse cleanly without quoting. + size_t i = 0; + std::string cur_key; + while (i < expr.size()) { + size_t comma = expr.find(',', i); + std::string tok = expr.substr(i, comma == std::string::npos ? std::string::npos : comma - i); + if (tok.empty()) { i = comma + 1; continue; } + + auto eq = tok.find('='); + std::string key, val; + if (eq != std::string::npos) { + key = tok.substr(0, eq); + val = tok.substr(eq + 1); + cur_key = key; + } else { + if (cur_key.empty()) { + err = "value '" + tok + "' with no key"; + return false; + } + val = tok; + } + + if (cur_key == "ethertype") { + int idx = ethertype_name_to_idx(val); + if (idx < 0) { + err = "unknown ethertype '" + val + "' (expected audio/disco/control/sync/other)"; + return false; + } + ethertype_mask |= (1u << idx); + } else if (cur_key == "src") { + uint16_t u; if (!parse_uid(val, u, err)) return false; + src_uids.insert(u); + } else if (cur_key == "dst") { + uint16_t u; if (!parse_uid(val, u, err)) return false; + dst_uids.insert(u); + } else if (cur_key == "peer" || cur_key == "uid") { + uint16_t u; if (!parse_uid(val, u, err)) return false; + peer_uids.insert(u); + } else { + err = "unknown filter key '" + cur_key + "'"; + return false; + } + + if (comma == std::string::npos) break; + i = comma + 1; + } + return true; +} diff --git a/tools/oaninspect/Filter.h b/tools/oaninspect/Filter.h new file mode 100644 index 0000000..6a7dfc7 --- /dev/null +++ b/tools/oaninspect/Filter.h @@ -0,0 +1,45 @@ +#ifndef OSST_OANINSPECT_FILTER_H +#define OSST_OANINSPECT_FILTER_H + +#include +#include +#include + +// Filter expression — comma-separated key=value[,value...] conditions. +// Keys are ANDed; values within a key are ORed. Examples: +// ethertype=audio,disco +// ethertype=control,src=42 +// peer=100 +// +// Keys: ethertype, src, dst, peer, uid (uid is alias for peer). +struct Filter { + // 5-bit mask of EtypeIdx::COUNT entries. 0 means "no ethertype clause" + // = pass everything; set bits mean "only these allowed". + uint8_t ethertype_mask{0}; + std::unordered_set src_uids; // empty = no clause + std::unordered_set dst_uids; + std::unordered_set peer_uids; // matches src OR dst + + bool match(uint8_t etype_idx, uint16_t src_uid, uint16_t dst_uid) const; + + // Cheap "could this ethertype ever match, ignoring src/dst clauses?" + // Used by the ingest fast-path to drop audio before allocating a ring + // entry — we want to know "is this filter audio-permissive?" without + // requiring the caller to know what src_uid to ask about. + bool accepts_ethertype(uint8_t etype_idx) const { + if (ethertype_mask == 0) return true; + return (ethertype_mask & (1u << etype_idx)) != 0; + } + + // Parse "k=v[,v2...][,k2=v3[,v4]]". Returns true on success; appends a + // human-readable error to `err` on failure. + bool parse(const std::string& expr, std::string& err); + + // True when no conditions are active. + bool empty() const { + return ethertype_mask == 0 && src_uids.empty() + && dst_uids.empty() && peer_uids.empty(); + } +}; + +#endif diff --git a/tools/oaninspect/main.cpp b/tools/oaninspect/main.cpp new file mode 100644 index 0000000..675496b --- /dev/null +++ b/tools/oaninspect/main.cpp @@ -0,0 +1,831 @@ +// oaninspect — passive OAN wire sniffer. +// +// Connects to sim_switch as a "promiscuous" client and prints every frame +// fanned out by the daemon, decoded per EtherType. Supports interactive +// filtering, pause + scroll-back, and an own-format pcap record/replay. +// +// Intentionally single-file for now: the TUI, ring buffer, and main loop +// are tightly coupled and would gain nothing from a multi-TU split. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sim_proto.h" +#include "Filter.h" +#include "Decoder.h" + +namespace { + +constexpr const char* DEFAULT_SOCKET_PATH = "/tmp/osst-sim-default.sock"; +constexpr uint32_t MAX_FRAME_PAYLOAD = 8192; // matches sim_switch +// Ring is big enough that ~10s of dense non-audio traffic (sync at a few +// Hz × small peer count + control bursts) survives even with audio also +// flowing — but the audio-skip-at-ingest path below means audio normally +// doesn't compete for ring slots unless the user explicitly filtered it in. +constexpr size_t DEFAULT_BUFFER = 100000; +constexpr uint16_t INSPECTOR_UID = 0xFFFE; + +// Custom record format for --pcap and --replay. NOT libpcap-compatible. +// File starts with this magic + version byte. Then a stream of: +// uint64_t timestamp_ms +// SimFrame header (sizeof(SimFrame) bytes) +// uint8_t payload[header.payload_len] +constexpr char OSTPCAP_MAGIC[7] = {'O','S','T','P','C','A','P'}; +constexpr uint8_t OSTPCAP_VERSION = 2; + +std::atomic g_shutdown_requested{false}; +void on_signal(int) { g_shutdown_requested = true; } + +uint64_t now_ms() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); +} +uint64_t wall_now_ms() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); +} + +struct Args { + std::string socket_path = DEFAULT_SOCKET_PATH; + std::string filter_expr; // empty → audio-suppressed default + bool filter_explicit{false}; // true if user passed --filter + bool hex{false}; + std::string pcap_path; + std::string replay_path; + size_t buffer{DEFAULT_BUFFER}; +}; + +void print_help() { + std::cout + << "oaninspect — passive OAN wire sniffer (talks to sim_switch)\n" + << "Usage: oaninspect [OPTIONS]\n" + << " --socket-path PATH default " << DEFAULT_SOCKET_PATH << "\n" + << " --filter EXPR e.g. 'ethertype=control,disco' or 'peer=42'\n" + << " keys: ethertype, src, dst, peer, uid\n" + << " (no --filter → audio is suppressed; explicit\n" + << " empty --filter '' = show everything)\n" + << " --hex also dump payload bytes (first 48B)\n" + << " --buffer N ring buffer size for pause/scroll (default " + << DEFAULT_BUFFER << ")\n" + << " --pcap FILE record frames to FILE (own format, not libpcap)\n" + << " --replay FILE read FILE instead of connecting to a daemon\n" + << "Keys at runtime: Space=pause j/k=scroll Ctrl-D/U=½page g/G=top/bottom\n" + << " / = re-enter filter h=toggle hex q=quit\n"; +} + +Args parse_args(int argc, char** argv) { + Args a; + for (int i = 1; i < argc; ++i) { + std::string s = argv[i]; + auto need = [&](const char* opt) { + if (i + 1 >= argc) { + std::cerr << "oaninspect: " << opt << " requires a value\n"; + std::exit(2); + } + return std::string(argv[++i]); + }; + if (s == "--socket-path") a.socket_path = need("--socket-path"); + else if (s == "--filter") { a.filter_expr = need("--filter"); a.filter_explicit = true; } + else if (s == "--hex") a.hex = true; + else if (s == "--buffer") a.buffer = std::strtoul(need("--buffer").c_str(), nullptr, 0); + else if (s == "--pcap") a.pcap_path = need("--pcap"); + else if (s == "--replay") a.replay_path = need("--replay"); + else if (s == "-h" || s == "--help") { print_help(); std::exit(0); } + else { + std::cerr << "oaninspect: unknown arg '" << s << "'\n"; + std::exit(2); + } + } + if (a.buffer == 0) a.buffer = DEFAULT_BUFFER; + return a; +} + +// One ring-buffer entry. Owns a copy of the payload bytes so the original +// recv buffer can be reused immediately on the hot path. +struct Entry { + uint64_t wall_ms; + uint16_t ethertype; + uint16_t src_uid; + uint16_t dst_uid; + uint8_t etype_idx; + std::vector payload; // raw bytes the switch fanned out +}; + +class Ring { +public: + explicit Ring(size_t cap) : m_cap(cap) {} + void push(Entry e) { + if (m_buf.size() == m_cap) m_buf.pop_front(); + m_buf.push_back(std::move(e)); + } + size_t size() const { return m_buf.size(); } + const Entry& at(size_t i) const { return m_buf[i]; } +private: + size_t m_cap; + std::deque m_buf; +}; + +int open_socket_or_die(const std::string& path) { + int fd = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { + std::cerr << "oaninspect: socket() failed: " << ::strerror(errno) << "\n"; + std::exit(1); + } + sockaddr_un a{}; + a.sun_family = AF_UNIX; + if (path.size() >= sizeof(a.sun_path)) { + std::cerr << "oaninspect: socket path too long\n"; + std::exit(1); + } + std::strncpy(a.sun_path, path.c_str(), sizeof(a.sun_path)-1); + if (::connect(fd, reinterpret_cast(&a), sizeof(a)) < 0) { + std::cerr << "oaninspect: connect to " << path + << " failed (is sim_switch running?): " + << ::strerror(errno) << "\n"; + std::exit(1); + } + + SimHello h{ + SIM_MAGIC, + SIM_VERSION, + 0, + 0, // ethertype — ignored by switch for promiscuous + INSPECTOR_UID, + SIM_HELLO_PROMISCUOUS + }; + if (::send(fd, &h, sizeof(h), 0) != (ssize_t)sizeof(h)) { + std::cerr << "oaninspect: hello send failed: " << ::strerror(errno) << "\n"; + std::exit(1); + } + + int flags = ::fcntl(fd, F_GETFL, 0); + ::fcntl(fd, F_SETFL, flags | O_NONBLOCK); + return fd; +} + +// Place stdin in cbreak (raw-ish) mode so we get keystrokes without Enter. +// Restored on exit via atexit + RAII shutdown. +struct termios g_orig_termios{}; +bool g_termios_saved = false; +void restore_termios() { + if (g_termios_saved) ::tcsetattr(STDIN_FILENO, TCSANOW, &g_orig_termios); +} +void enable_raw_stdin() { + if (!::isatty(STDIN_FILENO)) return; + ::tcgetattr(STDIN_FILENO, &g_orig_termios); + g_termios_saved = true; + std::atexit(restore_termios); + struct termios t = g_orig_termios; + t.c_lflag &= ~(ICANON | ECHO); + t.c_cc[VMIN] = 0; + t.c_cc[VTIME] = 0; + ::tcsetattr(STDIN_FILENO, TCSANOW, &t); +} + +// ANSI helpers. +void clear_screen() { std::cout << "\033[2J\033[H"; } +void move_cursor(int row, int col) { std::cout << "\033[" << row << ";" << col << "H"; } +void clear_line_to_end() { std::cout << "\033[K"; } + +int term_rows() { + // Lazy: a fixed default works fine; real terminals all support \033[18t + // or TIOCGWINSZ, but adding either bloats this for marginal gain. The + // tail-and-status layout is tolerant of a wrong height. + struct winsize ws{}; + if (::ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_row > 0) return ws.ws_row; + return 30; +} +int term_cols() { + struct winsize ws{}; + if (::ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) return ws.ws_col; + return 120; +} + +struct UiState { + Filter filter; + bool paused{false}; + bool hex{false}; + bool color{true}; + // Scroll offset in matching-entries from the bottom. 0 = follow newest. + int scroll_offset{0}; + // Counter for status bar suppression hint. + uint64_t audio_seen{0}; + uint64_t audio_first_ms{0}; + // Frames the wire delivered while pause was active. Resets on unpause + // so the count reflects "what you'd see if you unpause right now." + uint64_t paused_dropped{0}; + + // ---- diff-render state ----------------------------------------------- + // Snapshot of each rendered line. render() compares against this and + // only emits ANSI for rows whose content actually changed. Eliminates + // the full-screen redraw flicker. + std::vector prev_lines; + std::string prev_status; + int prev_rows{0}; + int prev_cols{0}; + bool needs_full_redraw{true}; +}; + +// Compact, always-visible key cheat-sheet rendered above the status bar. +// Kept in one place so adding/removing a binding only needs editing here. +constexpr const char* KEY_HELP = + "[Space]pause [j/k]scroll [Ctrl-D/U] half-pg [g/G]top/end [/]filter [h]hex [q]quit"; + +// Render the visible portion of the buffer + key-help + status bar. +// +// Strategy: each refresh builds the full vector of lines we WANT on the +// screen, then diffs against `st.prev_lines` and only emits ANSI for rows +// whose content differs. Avoids the clear-screen flicker that made the +// old version look like it never stopped scrolling. +void render(const Ring& ring, UiState& st, const Args& args) { + int rows = term_rows(); + // Last row = status bar; row above it = key-help footer. Both fixed. + int body_rows = rows - 2; + if (body_rows < 1) body_rows = 1; + int cols = term_cols(); + + // Force a clean redraw if the terminal resized or this is the first + // paint. Otherwise leftover content from the previous geometry would + // get stranded on screen. + if (rows != st.prev_rows || cols != st.prev_cols) { + st.needs_full_redraw = true; + } + + // Collect matching entries (indices into ring) up to what we can render + // from the current scroll position. + std::vector matches; + matches.reserve(ring.size()); + for (size_t i = 0; i < ring.size(); ++i) { + const auto& e = ring.at(i); + if (st.filter.match(e.etype_idx, e.src_uid, e.dst_uid)) { + matches.push_back(i); + } + } + + // Determine which slice we show: tail (paused or unpaused, scroll=0) + // shows the LAST body_rows matches; scrolling pulls the window up. + int end = (int)matches.size() - st.scroll_offset; + int start = end - body_rows; + if (end > (int)matches.size()) end = (int)matches.size(); + if (start < 0) start = 0; + if (end < start) end = start; + + DecodeOpts opts{st.hex, st.color}; + + // Build the vector of lines we want on screen this tick. Empty strings + // represent blank body rows. + std::vector new_lines(body_rows); + int slot = 0; + for (int i = start; i < end && slot < body_rows; ++i) { + const auto& e = ring.at(matches[i]); + std::string s = decode_frame_line(e.ethertype, e.src_uid, e.dst_uid, + e.payload.data(), e.payload.size(), + e.wall_ms, opts); + if ((int)s.size() > cols) s.resize(cols); + new_lines[slot++] = std::move(s); + } + + // Status bar. + std::ostringstream bar; + bar << (st.paused ? "PAUSED" : "LIVE ") + << " filter: " + << (st.filter.empty() ? std::string("(none)") : args.filter_expr); + bar << " shown: " << matches.size() << "/" << ring.size(); + if (st.paused && st.paused_dropped > 0) { + bar << " pending: " << st.paused_dropped; + } + bool audio_in_filter = st.filter.accepts_ethertype(0); + if (!audio_in_filter && st.audio_seen > 0) { + uint64_t span_ms = wall_now_ms() - st.audio_first_ms; + double rate = span_ms > 0 ? (1000.0 * st.audio_seen / span_ms) : 0.0; + bar << " audio-suppressed: " << (int)rate << "/s"; + } + std::string bar_s = bar.str(); + if ((int)bar_s.size() > cols) bar_s.resize(cols); + + // Initial paint or resize: clear once, write all body slots fresh, and + // paint the static key-help footer. Key help only redrawn here because + // the string is constant; tickly diffing it every refresh is wasted. + if (st.needs_full_redraw) { + clear_screen(); + st.prev_lines.assign(body_rows, std::string(1, '\x01')); // sentinel != empty + st.prev_status.clear(); + + std::string help = KEY_HELP; + if ((int)help.size() > cols) help.resize(cols); + move_cursor(rows - 1, 1); + std::cout << "\033[2m" << help << "\033[0m"; // dim + clear_line_to_end(); + + st.needs_full_redraw = false; + } + + // Resize prev_lines to current body height (terminal may have shrunk + // since last full redraw was skipped). + if ((int)st.prev_lines.size() != body_rows) { + st.prev_lines.resize(body_rows); + } + + // Diff body rows: only redraw rows whose content actually changed. + for (int r = 0; r < body_rows; ++r) { + const std::string& want = new_lines[r]; + if (st.prev_lines[r] == want) continue; + move_cursor(r + 1, 1); + std::cout << want; + clear_line_to_end(); + st.prev_lines[r] = want; + } + + // Diff status bar. + if (st.prev_status != bar_s) { + move_cursor(rows, 1); + std::cout << "\033[7m" << bar_s; + // Pad inverse to full width. + for (int i = (int)bar_s.size(); i < cols; ++i) std::cout << ' '; + std::cout << "\033[0m"; + st.prev_status = bar_s; + } + + st.prev_rows = rows; + st.prev_cols = cols; + + std::cout << std::flush; +} + +// Prompt for a new filter expression at the bottom row. Blocking read with +// canonical termios so backspace + Enter work normally; restore raw on exit. +std::string prompt_filter(const std::string& prefill) { + int rows = term_rows(); + move_cursor(rows, 1); + std::cout << "\033[7m/" << "\033[0m " << std::flush; + + // Temporarily restore cooked mode for line edit. + if (g_termios_saved) ::tcsetattr(STDIN_FILENO, TCSANOW, &g_orig_termios); + + std::string s = prefill; + // We re-echo the prefill ourselves so the user can edit it. Simplest: + // read a single line via std::getline, ignoring prefill — the user + // re-types from scratch. (Saves wiring up a real line editor.) + (void)s; + std::string in; + std::getline(std::cin, in); + + // Back to raw. + if (g_termios_saved) { + struct termios t = g_orig_termios; + t.c_lflag &= ~(ICANON | ECHO); + t.c_cc[VMIN] = 0; + t.c_cc[VTIME] = 0; + ::tcsetattr(STDIN_FILENO, TCSANOW, &t); + } + return in; +} + +// Pcap I/O — own format. Header magic + version, then records. +class PcapWriter { +public: + bool open(const std::string& path) { + m_fp = std::fopen(path.c_str(), "wb"); + if (!m_fp) { + std::cerr << "oaninspect: cannot open " << path + << ": " << ::strerror(errno) << "\n"; + return false; + } + std::fwrite(OSTPCAP_MAGIC, 1, sizeof(OSTPCAP_MAGIC), m_fp); + std::fputc(OSTPCAP_VERSION, m_fp); + return true; + } + void write(uint64_t ts_ms, const SimFrame& hdr, const uint8_t* payload) { + if (!m_fp) return; + std::fwrite(&ts_ms, sizeof(ts_ms), 1, m_fp); + std::fwrite(&hdr, sizeof(hdr), 1, m_fp); + std::fwrite(payload, 1, hdr.payload_len, m_fp); + } + ~PcapWriter() { if (m_fp) std::fclose(m_fp); } +private: + FILE* m_fp{nullptr}; +}; + +class PcapReader { +public: + bool open(const std::string& path) { + m_fp = std::fopen(path.c_str(), "rb"); + if (!m_fp) { + std::cerr << "oaninspect: cannot open " << path + << ": " << ::strerror(errno) << "\n"; + return false; + } + char magic[sizeof(OSTPCAP_MAGIC)]; + if (std::fread(magic, 1, sizeof(magic), m_fp) != sizeof(magic) + || std::memcmp(magic, OSTPCAP_MAGIC, sizeof(magic)) != 0) { + std::cerr << "oaninspect: " << path << " is not an OSTPCAP file\n"; + return false; + } + int v = std::fgetc(m_fp); + if (v != OSTPCAP_VERSION) { + std::cerr << "oaninspect: OSTPCAP version mismatch (file=" << v + << " expected=" << (int)OSTPCAP_VERSION << ")\n"; + return false; + } + return true; + } + // Reads next record. Returns false on EOF or error. + bool next(uint64_t& ts_ms, SimFrame& hdr, std::vector& payload) { + if (!m_fp) return false; + if (std::fread(&ts_ms, sizeof(ts_ms), 1, m_fp) != 1) return false; + if (std::fread(&hdr, sizeof(hdr), 1, m_fp) != 1) return false; + if (hdr.payload_len > MAX_FRAME_PAYLOAD) return false; + payload.resize(hdr.payload_len); + if (std::fread(payload.data(), 1, hdr.payload_len, m_fp) != hdr.payload_len) return false; + return true; + } + ~PcapReader() { if (m_fp) std::fclose(m_fp); } +private: + FILE* m_fp{nullptr}; +}; + +// Stream-parser state for incoming SimFrames over the socket. Tolerates +// partial reads. +struct WireParser { + bool have_hdr{false}; + SimFrame hdr{}; + size_t hdr_read{0}; + std::vector payload; + size_t payload_read{0}; + + // Drains bytes from `data` into the current frame. For each complete + // frame, invokes `on_complete(hdr, payload)`. Returns true on success; + // false if a malformed frame was seen (caller should disconnect). + template + bool feed(const uint8_t* data, size_t n, F&& on_complete) { + size_t i = 0; + while (i < n) { + if (!have_hdr) { + size_t need = sizeof(SimFrame) - hdr_read; + size_t take = std::min(need, n - i); + std::memcpy(reinterpret_cast(&hdr) + hdr_read, data + i, take); + hdr_read += take; + i += take; + if (hdr_read < sizeof(SimFrame)) return true; + if (hdr.payload_len > MAX_FRAME_PAYLOAD) return false; + payload.resize(hdr.payload_len); + payload_read = 0; + have_hdr = true; + } + if (have_hdr) { + size_t need = hdr.payload_len - payload_read; + size_t take = std::min(need, n - i); + if (take > 0) std::memcpy(payload.data() + payload_read, data + i, take); + payload_read += take; + i += take; + if (payload_read < hdr.payload_len) return true; + on_complete(hdr, payload); + have_hdr = false; + hdr_read = 0; + payload_read = 0; + } + } + return true; + } +}; + +} // namespace + +int main(int argc, char** argv) { + Args args = parse_args(argc, argv); + + struct sigaction sa{}; + sa.sa_handler = on_signal; + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_RESTART; + ::sigaction(SIGINT, &sa, nullptr); + ::sigaction(SIGTERM, &sa, nullptr); + ::signal(SIGPIPE, SIG_IGN); + + UiState st; + st.hex = args.hex; + st.color = ::isatty(STDOUT_FILENO); + + // Default filter behaviour: if user didn't pass --filter, suppress + // audio (it dominates the wire). Explicit empty --filter '' = wide open. + std::string effective_filter = args.filter_expr; + if (!args.filter_explicit) { + effective_filter = "ethertype=disco,control,sync"; + } + { + std::string err; + if (!st.filter.parse(effective_filter, err)) { + std::cerr << "oaninspect: filter parse: " << err << "\n"; + return 2; + } + } + // Stash the displayed filter expression for the status bar. + args.filter_expr = effective_filter; + + Ring ring(args.buffer); + + PcapWriter pw; + if (!args.pcap_path.empty() && !pw.open(args.pcap_path)) return 1; + + // The on-frame path is shared between live recv and replay so pause/ + // filter/scroll work identically. + // + // Pause semantics: while paused, frames keep arriving from the wire + // but are *dropped*, not buffered. This freezes the displayed history + // exactly where the user paused — otherwise the ring would silently + // evict the very entries the user paused to read. `paused_dropped` + // tracks how many frames got skipped so the status bar can show it. + // Pcap recording, however, continues regardless — recording a session + // to replay later is independent of what you're looking at right now. + // + // Audio fast-path: even at 100k ring capacity, 4000 audio frames/s + // would push every disco/sync/control entry out within ~25 seconds. So + // if the current filter excludes audio, we skip audio at ingest — the + // ring only sees frames the user actually wants. Changing the filter + // later to *include* audio means past audio is gone (as discussed — + // acceptable trade), but everything else (sync, disco, control) + // remains available for retroactive filtering while paused. + // The `audio_seen` counter bumps regardless so the suppression hint + // in the status bar still reflects reality. + auto on_frame = [&](const SimFrame& hdr, const std::vector& payload, + uint64_t wall_ms) { + if (!args.pcap_path.empty()) pw.write(wall_ms, hdr, payload.data()); + + uint8_t idx = etype_index_of(hdr.ethertype); + if (idx == 0) { + if (st.audio_seen == 0) st.audio_first_ms = wall_ms; + st.audio_seen++; + // Skip the push if the current filter excludes audio by + // ethertype. (src/dst clauses are intentionally NOT consulted + // here — even if the user has `src=42`, if they didn't + // explicitly exclude audio we keep buffering it so a future + // filter change that targets it has something to show.) + if (!st.filter.accepts_ethertype(0)) return; + } + + if (st.paused) { + st.paused_dropped++; + return; + } + + Entry e; + e.wall_ms = wall_ms; + e.ethertype = hdr.ethertype; + e.src_uid = hdr.src_uid; + e.dst_uid = hdr.dest_uid; + e.etype_idx = idx; + e.payload = payload; + ring.push(std::move(e)); + }; + + enable_raw_stdin(); + bool stdin_is_tty = ::isatty(STDIN_FILENO); + + // Replay path: just stream the file through on_frame and render once, + // then drop into the input loop for browsing. + if (!args.replay_path.empty()) { + PcapReader pr; + if (!pr.open(args.replay_path)) return 1; + uint64_t ts; SimFrame hdr; std::vector payload; + while (pr.next(ts, hdr, payload)) on_frame(hdr, payload, ts); + st.paused = true; // replay defaults to paused so user can scroll + st.scroll_offset = 0; + + // Non-TTY (replay piped or stdin closed): there's nothing to + // interact with, so dump every matching frame to stdout once and + // exit. Useful for scripting / sanity-checks of a recorded file. + if (!stdin_is_tty) { + DecodeOpts opts{st.hex, false}; + for (size_t i = 0; i < ring.size(); ++i) { + const auto& e = ring.at(i); + if (!st.filter.match(e.etype_idx, e.src_uid, e.dst_uid)) continue; + std::cout << decode_frame_line(e.ethertype, e.src_uid, e.dst_uid, + e.payload.data(), e.payload.size(), + e.wall_ms, opts) << "\n"; + } + return 0; + } + + render(ring, st, args); + + while (!g_shutdown_requested.load()) { + pollfd p{STDIN_FILENO, POLLIN, 0}; + int pr_rc = ::poll(&p, 1, 200); + if (pr_rc < 0) { + if (errno == EINTR) continue; + break; + } + if (pr_rc == 0) { render(ring, st, args); continue; } + char c; + if (::read(STDIN_FILENO, &c, 1) != 1) continue; + + bool quit = false; + int body_rows = std::max(1, term_rows() - 1); + switch (c) { + case 'q': case 3 /*Ctrl-C*/: quit = true; break; + case ' ': + st.paused = !st.paused; + if (!st.paused) st.paused_dropped = 0; + break; + case 'j': st.scroll_offset = std::max(0, st.scroll_offset - 1); break; + case 'k': st.scroll_offset = std::min((int)ring.size(), st.scroll_offset + 1); break; + case 4 /*Ctrl-D*/: st.scroll_offset = std::max(0, st.scroll_offset - body_rows/2); break; + case 21/*Ctrl-U*/: st.scroll_offset = std::min((int)ring.size(), st.scroll_offset + body_rows/2); break; + case 'g': st.scroll_offset = (int)ring.size(); break; + case 'G': st.scroll_offset = 0; break; + case 'h': st.hex = !st.hex; break; + case '/': { + std::string s = prompt_filter(args.filter_expr); + std::string err; + Filter f; + if (f.parse(s, err)) { + st.filter = std::move(f); + args.filter_expr = s; + } else { + // Re-render then show error line briefly. Simplest: + // squash it into the filter expression for visibility. + args.filter_expr = "(parse error: " + err + ")"; + } + st.scroll_offset = 0; + // prompt_filter wrote to the bottom row; force a clean + // repaint so the prompt artifact doesn't linger. + st.needs_full_redraw = true; + break; + } + default: break; + } + if (quit) break; + render(ring, st, args); + } + return 0; + } + + // Live path: socket + stdin. + int sock = open_socket_or_die(args.socket_path); + WireParser parser; + std::vector rx_buf(4096); + + uint64_t last_render_ms = 0; + bool stdin_open = stdin_is_tty; // only poll stdin if it's a real tty + + // Non-tty live path (stdin closed / piped): act like a plain ANSI tail — + // emit a line per matching frame to stdout, no curses, no pause. Used by + // scripts/CI to capture decoded traffic. + if (!stdin_is_tty) { + DecodeOpts opts{st.hex, false}; + pollfd pfds[1] = { { sock, POLLIN, 0 } }; + while (!g_shutdown_requested.load()) { + int rc = ::poll(pfds, 1, 200); + if (rc < 0) { if (errno == EINTR) continue; break; } + if (rc == 0) continue; + if (!(pfds[0].revents & POLLIN)) { + // hangup / error + break; + } + ssize_t n = ::read(sock, rx_buf.data(), rx_buf.size()); + if (n <= 0) { + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) continue; + if (n < 0 && errno == EINTR) continue; + break; + } + bool ok = parser.feed(rx_buf.data(), n, + [&](const SimFrame& hdr, const std::vector& p) { + on_frame(hdr, p, wall_now_ms()); + uint8_t idx = etype_index_of(hdr.ethertype); + if (!st.filter.match(idx, hdr.src_uid, hdr.dest_uid)) return; + std::cout << decode_frame_line(hdr.ethertype, hdr.src_uid, + hdr.dest_uid, p.data(), p.size(), + wall_now_ms(), opts) << "\n"; + }); + if (!ok) { + std::cerr << "oaninspect: malformed frame, exiting\n"; + break; + } + std::cout.flush(); + } + ::close(sock); + return 0; + } + + while (!g_shutdown_requested.load()) { + pollfd pfds[2] = { + { sock, POLLIN, 0 }, + { STDIN_FILENO, stdin_open ? (short)POLLIN : (short)0, 0 } + }; + int rc = ::poll(pfds, stdin_open ? 2 : 1, 200); + if (rc < 0) { + if (errno == EINTR) continue; + break; + } + + if (rc > 0) { + if (pfds[0].revents & POLLIN) { + while (true) { + ssize_t n = ::read(sock, rx_buf.data(), rx_buf.size()); + if (n > 0) { + bool ok = parser.feed(rx_buf.data(), n, + [&](const SimFrame& hdr, const std::vector& p) { + on_frame(hdr, p, wall_now_ms()); + }); + if (!ok) { + std::cerr << "\noaninspect: malformed frame, exiting\n"; + g_shutdown_requested = true; + break; + } + continue; + } + if (n == 0) { + // Daemon closed. Render once with whatever we have + // and bail. + std::cerr << "\noaninspect: daemon closed connection\n"; + g_shutdown_requested = true; + break; + } + if (errno == EAGAIN || errno == EWOULDBLOCK) break; + if (errno == EINTR) continue; + g_shutdown_requested = true; + break; + } + } + if (stdin_open && (pfds[1].revents & (POLLIN|POLLHUP))) { + char c; + ssize_t rn; + bool got_any = false; + while ((rn = ::read(STDIN_FILENO, &c, 1)) == 1) { + got_any = true; + bool quit = false; + int body_rows = std::max(1, term_rows() - 1); + switch (c) { + case 'q': case 3: quit = true; break; + case ' ': + st.paused = !st.paused; + if (!st.paused) st.paused_dropped = 0; + break; + case 'j': st.scroll_offset = std::max(0, st.scroll_offset - 1); break; + case 'k': st.scroll_offset = std::min((int)ring.size(), st.scroll_offset + 1); break; + case 4: st.scroll_offset = std::max(0, st.scroll_offset - body_rows/2); break; + case 21: st.scroll_offset = std::min((int)ring.size(), st.scroll_offset + body_rows/2); break; + case 'g': st.scroll_offset = (int)ring.size(); break; + case 'G': st.scroll_offset = 0; break; + case 'h': st.hex = !st.hex; break; + case '/': { + std::string s = prompt_filter(args.filter_expr); + std::string err; + Filter f; + if (f.parse(s, err)) { + st.filter = std::move(f); + args.filter_expr = s; + } else { + args.filter_expr = "(parse error: " + err + ")"; + } + st.scroll_offset = 0; + break; + } + default: break; + } + if (quit) { g_shutdown_requested = true; break; } + } + // If read returned 0 (EOF) and we got no bytes this round, + // stdin has closed (e.g. terminal hung up). Stop polling it. + if (rn == 0 && !got_any) stdin_open = false; + } + } + + // Throttle redraw to ~5Hz so a busy wire (high audio rate, filter + // off) doesn't melt the terminal. + uint64_t t = now_ms(); + if (t - last_render_ms >= 200 || rc > 0) { + if (!st.paused) st.scroll_offset = 0; + render(ring, st, args); + last_render_ms = t; + } + } + + // Cursor + screen cleanup. Leave a final newline so the shell prompt + // doesn't land mid-status-bar. + move_cursor(term_rows(), 1); + std::cout << "\033[0m\n" << std::flush; + ::close(sock); + return 0; +} diff --git a/tools/sim_switch/CMakeLists.txt b/tools/sim_switch/CMakeLists.txt index a7b873a..9672073 100644 --- a/tools/sim_switch/CMakeLists.txt +++ b/tools/sim_switch/CMakeLists.txt @@ -17,11 +17,15 @@ if(OAN_HOST_BACKENDS) find_package(GTest QUIET) if(GTest_FOUND) enable_testing() - add_executable(sim_switch_test test/test_sim_switch.cpp) + add_executable(sim_switch_test + test/test_sim_switch.cpp + ${CMAKE_SOURCE_DIR}/tools/oaninspect/Filter.cpp + ) target_compile_features(sim_switch_test PRIVATE cxx_std_20) target_include_directories(sim_switch_test PRIVATE ${CMAKE_SOURCE_DIR}/OpenAudioNetwork ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/tools/oaninspect ) target_link_libraries(sim_switch_test PRIVATE GTest::gtest GTest::gtest_main diff --git a/tools/sim_switch/Switch.cpp b/tools/sim_switch/Switch.cpp index 92a7bc9..476c0b5 100644 --- a/tools/sim_switch/Switch.cpp +++ b/tools/sim_switch/Switch.cpp @@ -48,7 +48,7 @@ void Switch::remove_conn(int fd) { size_t idx = idx_it->second; Conn& c = m_conns[idx]; - if (c.hello_received) { + if (c.hello_received && !c.promiscuous) { uint32_t key = (uint32_t(static_cast(c.ethertype)) << 16) | c.self_uid; auto rt = m_route_table.find(key); if (rt != m_route_table.end() && rt->second == fd) { @@ -120,11 +120,17 @@ int Switch::consume_hello(Conn& c) { c.ethertype = static_cast(h.ethertype); c.self_uid = h.self_uid; + c.promiscuous = (h.flags & SIM_HELLO_PROMISCUOUS) != 0; c.hello_received = true; c.rx_buf.erase(c.rx_buf.begin(), c.rx_buf.begin() + sizeof(SimHello)); - uint32_t key = (uint32_t(h.ethertype) << 16) | h.self_uid; - m_route_table[key] = c.fd; + // Promiscuous conns are observers, not addressable destinations — keep + // them out of the route table so they can't intercept unicasts meant + // for a real peer of the same uid. + if (!c.promiscuous) { + uint32_t key = (uint32_t(h.ethertype) << 16) | h.self_uid; + m_route_table[key] = c.fd; + } return 1; } @@ -143,6 +149,15 @@ int Switch::consume_frame(Conn& c) { if (c.rx_buf.size() < sizeof(SimFrame) + hdr.payload_len) return 0; + // 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; + // Mirror back into the rx_buf so the bytes fanout writes to peers are + // the corrected ones, not the sender's zero. fanout reads `hdr` (a + // local copy) for the header, so updating the buffered copy is purely + // 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 @@ -151,6 +166,12 @@ int Switch::consume_frame(Conn& c) { m_stats.bytes_in[idx] += hdr.payload_len; if (hdr.dest_uid == 0) m_stats.bcast_in[idx]++; + // Per-peer tx attribution. Promiscuous conns can also send (rare, but + // legal) — they get counted under their hello uid like any peer. + auto& peer = m_peer_stats[c.self_uid]; + peer.tx[idx]++; + peer.last_activity_ms = m_now_ms; + // TUI enrichment (bounded — only disco) if (hdr.ethertype == ETH_PROTO_OANDISCO) { m_disco.observe(payload, hdr.payload_len, m_now_ms); @@ -164,23 +185,50 @@ int Switch::consume_frame(Conn& c) { } void Switch::fanout(const Conn& sender, const SimFrame& hdr, const uint8_t* payload) { + int idx = (int)etype_index(hdr.ethertype); + if (hdr.dest_uid == 0) { for (auto& target : m_conns) { if (target.fd == sender.fd) continue; if (!target.hello_received) continue; + // Promiscuous conns are handled in the mirror pass below so we + // don't double-deliver to them when their hello ethertype + // happens to match. + if (target.promiscuous) continue; if (target.ethertype != static_cast(hdr.ethertype)) continue; try_write(target, reinterpret_cast(&hdr), sizeof(hdr), payload, hdr.payload_len); + auto& p = m_peer_stats[target.self_uid]; + p.rx[idx]++; + p.last_activity_ms = m_now_ms; } } else { uint32_t key = (uint32_t(hdr.ethertype) << 16) | hdr.dest_uid; auto it = m_route_table.find(key); - if (it == m_route_table.end()) return; // unknown UID — drop silently - if (it->second == sender.fd) return; - Conn* target = find_conn(it->second); - if (!target) return; - try_write(*target, + if (it != m_route_table.end() && it->second != sender.fd) { + Conn* target = find_conn(it->second); + if (target) { + try_write(*target, + reinterpret_cast(&hdr), sizeof(hdr), + payload, hdr.payload_len); + auto& p = m_peer_stats[target->self_uid]; + p.rx[idx]++; + p.last_activity_ms = m_now_ms; + } + } + // Fall through to the promiscuous mirror pass even for unknown + // unicasts so observers see "uid X tried to talk to nobody" traffic. + } + + // Promiscuous mirror pass — every fanout, regardless of ethertype or + // dest_uid match. Inspectors don't bump per-peer rx (they're not + // production endpoints; counting their rx would skew the peers table). + for (auto& target : m_conns) { + if (!target.promiscuous) continue; + if (target.fd == sender.fd) continue; + if (!target.hello_received) continue; + try_write(target, reinterpret_cast(&hdr), sizeof(hdr), payload, hdr.payload_len); } @@ -217,13 +265,32 @@ void Switch::prune_disco(uint64_t now_ms, uint64_t max_age_ms) { m_disco.prune(now_ms, max_age_ms); } +void Switch::prune_peer_stats(uint64_t now_ms, uint64_t max_age_ms) { + for (auto it = m_peer_stats.begin(); it != m_peer_stats.end(); ) { + if (it->second.last_activity_ms == 0 + || now_ms - it->second.last_activity_ms > max_age_ms) { + it = m_peer_stats.erase(it); + } else { + ++it; + } + } +} + std::vector Switch::conns() const { std::vector out; out.reserve(m_conns.size()); for (const auto& c : m_conns) { - out.push_back({c.fd, c.hello_received, + out.push_back({c.fd, c.hello_received, c.promiscuous, static_cast(c.ethertype), c.self_uid, c.drops}); } return out; } + +int Switch::inspector_count() const { + int n = 0; + for (const auto& c : m_conns) { + if (c.hello_received && c.promiscuous) n++; + } + return n; +} diff --git a/tools/sim_switch/Switch.h b/tools/sim_switch/Switch.h index d67e05f..fdc9ce4 100644 --- a/tools/sim_switch/Switch.h +++ b/tools/sim_switch/Switch.h @@ -30,11 +30,20 @@ class Switch { struct ConnSummary { int fd; bool hello_received; + bool promiscuous; uint16_t ethertype; uint16_t self_uid; uint64_t drops; }; + // Per-peer (uid) tx/rx counters indexed by EtypeIdx. Switch maintains + // monotonic counters; TUI smooths into per-second rates. + struct PeerStats { + uint64_t tx[(int)EtypeIdx::COUNT]{}; + uint64_t rx[(int)EtypeIdx::COUNT]{}; + uint64_t last_activity_ms{0}; + }; + void on_accept(int fd); void on_hangup(int fd); // Returns false if the conn should be closed (bad hello, framing error). @@ -43,9 +52,13 @@ class Switch { const Stats& stats() const { return m_stats; } const DiscoveryPeek& disco() const { return m_disco; } std::vector conns() const; + const std::unordered_map& peer_stats() const { return m_peer_stats; } + // Number of attached promiscuous (inspector) connections. + int inspector_count() const; void set_now_ms(uint64_t now_ms) { m_now_ms = now_ms; } void prune_disco(uint64_t now_ms, uint64_t max_age_ms); + void prune_peer_stats(uint64_t now_ms, uint64_t max_age_ms); static EtypeIdx etype_index(uint16_t e); @@ -54,6 +67,7 @@ class Switch { int fd{-1}; bool hello_received{false}; bool must_close{false}; + bool promiscuous{false}; EthProtocol ethertype{}; uint16_t self_uid{0}; std::vector rx_buf; @@ -74,11 +88,13 @@ class Switch { // fd → index into m_conns. Kept in sync with m_conns to give O(1) // lookup on the hot path (every readable event and unicast fanout). std::unordered_map m_fd_to_idx; - // key = (ethertype << 16) | dest_uid → conn fd + // key = (ethertype << 16) | dest_uid → conn fd. Promiscuous conns are + // not registered here — they're not a destination uid. std::unordered_map m_route_table; DiscoveryPeek m_disco; Stats m_stats; + std::unordered_map m_peer_stats; uint64_t m_now_ms{0}; }; diff --git a/tools/sim_switch/Tui.cpp b/tools/sim_switch/Tui.cpp index 512a2a9..080b145 100644 --- a/tools/sim_switch/Tui.cpp +++ b/tools/sim_switch/Tui.cpp @@ -1,6 +1,7 @@ #include "Tui.h" #include "Switch.h" +#include #include #include #include @@ -51,6 +52,14 @@ uint16_t etype_value(int idx) { } } +// Compact "tx/rx" pair for the Peers row. Pads to a consistent width so +// columns line up across rows. +std::string fmt_pair(double tx, double rx) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%6.1f/%-6.1f", tx, rx); + return buf; +} + } // namespace namespace { @@ -104,26 +113,50 @@ void Tui::refresh(const Switch& sw, uint64_t now_ms) { double dt_s = (now_ms - m_prev_now_ms) / 1000.0; if (dt_s <= 0) dt_s = 0.001; + // Update EWMA rates from instantaneous deltas. Done in refresh() so it + // happens regardless of headless vs. TUI rendering — the headless path + // wants smoothed values too. + for (int i = 0; i < 5; ++i) { + double inst_fps = (sw.stats().frames_in[i] - m_prev_frames[i]) / dt_s; + double inst_bps = (sw.stats().bytes_in[i] - m_prev_bytes[i]) / dt_s; + m_frame_rate[i].update(inst_fps); + m_byte_rate[i].update(inst_bps); + } + for (const auto& [uid, ps] : sw.peer_stats()) { + auto& snap = m_peer_snaps[uid]; + for (int i = 0; i < 5; ++i) { + double inst_tx = (ps.tx[i] - snap.tx[i]) / dt_s; + double inst_rx = (ps.rx[i] - snap.rx[i]) / dt_s; + snap.tx_rate[i].update(inst_tx); + snap.rx_rate[i].update(inst_rx); + snap.tx[i] = ps.tx[i]; + snap.rx[i] = ps.rx[i]; + } + } + // Drop snapshots for peers the switch has pruned, so the Peers table + // doesn't keep showing dead uids. + for (auto it = m_peer_snaps.begin(); it != m_peer_snaps.end(); ) { + if (sw.peer_stats().find(it->first) == sw.peer_stats().end()) { + it = m_peer_snaps.erase(it); + } else { + ++it; + } + } + if (m_headless) { if (now_ms - m_last_headless_ms >= 5000) { render_headless(sw, dt_s, now_ms); m_last_headless_ms = now_ms; - // Snapshot for next dt calc: - m_prev_now_ms = now_ms; - for (int i = 0; i < 5; ++i) { - m_prev_frames[i] = sw.stats().frames_in[i]; - m_prev_bytes[i] = sw.stats().bytes_in[i]; - m_prev_bcast[i] = sw.stats().bcast_in[i]; - } } } else { render_tui(sw, dt_s); - m_prev_now_ms = now_ms; - for (int i = 0; i < 5; ++i) { - m_prev_frames[i] = sw.stats().frames_in[i]; - m_prev_bytes[i] = sw.stats().bytes_in[i]; - m_prev_bcast[i] = sw.stats().bcast_in[i]; - } + } + + m_prev_now_ms = now_ms; + for (int i = 0; i < 5; ++i) { + m_prev_frames[i] = sw.stats().frames_in[i]; + m_prev_bytes[i] = sw.stats().bytes_in[i]; + m_prev_bcast[i] = sw.stats().bcast_in[i]; } } @@ -140,31 +173,32 @@ void Tui::emit_line(const std::string& line) { std::cout << "\033[2K\r" << line << "\n"; } -void Tui::render_tui(const Switch& sw, double dt_s) { +void Tui::render_tui(const Switch& sw, double /*dt_s*/) { erase_previous(); std::vector lines; auto conns = sw.conns(); + int inspectors = sw.inspector_count(); { std::ostringstream l; l << "sim_switch " << m_socket_path - << " conns=" << conns.size(); + << " conns=" << conns.size() + << " inspectors=" << inspectors; lines.push_back(l.str()); } lines.push_back(""); - lines.push_back("Traffic (msg/s / KiB/s / bcast%):"); + lines.push_back("Traffic (msg/s / KiB/s / bcast%, smoothed):"); for (int i = 0; i < 5; ++i) { uint64_t df = sw.stats().frames_in[i] - m_prev_frames[i]; - uint64_t db = sw.stats().bytes_in[i] - m_prev_bytes[i]; uint64_t dc = sw.stats().bcast_in[i] - m_prev_bcast[i]; - if (i == 4 && df == 0 && sw.stats().frames_in[i] == 0) continue; + if (i == 4 && m_frame_rate[i].smoothed < 0.05 && sw.stats().frames_in[i] == 0) continue; - double fps = df / dt_s; - double kbps = (db / dt_s) / 1024.0; + double fps = m_frame_rate[i].smoothed; + double kbps = m_byte_rate[i].smoothed / 1024.0; double bcast_pct = df > 0 ? (100.0 * dc / df) : 0.0; std::ostringstream l; @@ -177,6 +211,39 @@ void Tui::render_tui(const Switch& sw, double dt_s) { lines.push_back(l.str()); } + // Peers table — built from peer_stats keyed by uid, joined to the + // discovery table for the friendly device name. + lines.push_back(""); + lines.push_back("Peers (msg/s — A/D/C/S = audio/disco/control/sync, tx/rx):"); + if (m_peer_snaps.empty()) { + lines.push_back(" (no peers active)"); + } else { + // Stable display: sort by uid. Hash-map order would flicker. + std::vector uids; + uids.reserve(m_peer_snaps.size()); + for (const auto& [uid, _] : m_peer_snaps) uids.push_back(uid); + std::sort(uids.begin(), uids.end()); + + for (uint16_t uid : uids) { + const auto& snap = m_peer_snaps[uid]; + // Look up the friendly name from discovery if we have it. + std::string name = "(unknown)"; + auto it = sw.disco().devices().find(uid); + if (it != sw.disco().devices().end() && !it->second.name.empty()) { + name = it->second.name; + } + + std::ostringstream l; + l << " uid=" << std::setw(5) << uid + << " " << std::left << std::setw(20) << name << std::right + << " A:" << fmt_pair(snap.tx_rate[0].smoothed, snap.rx_rate[0].smoothed) + << " D:" << fmt_pair(snap.tx_rate[1].smoothed, snap.rx_rate[1].smoothed) + << " C:" << fmt_pair(snap.tx_rate[2].smoothed, snap.rx_rate[2].smoothed) + << " S:" << fmt_pair(snap.tx_rate[3].smoothed, snap.rx_rate[3].smoothed); + lines.push_back(l.str()); + } + } + lines.push_back(""); lines.push_back("Devices:"); if (sw.disco().devices().empty()) { @@ -201,9 +268,13 @@ void Tui::render_tui(const Switch& sw, double dt_s) { lines.push_back(" (none)"); } else { for (const auto& c : conns) { + const char* state = + !c.hello_received ? "PRE " : + c.promiscuous ? "INSP " : + "OK "; std::ostringstream l; l << " fd=" << std::setw(3) << c.fd - << " " << (c.hello_received ? "OK " : "PRE ") + << " " << state << " etype=0x" << std::hex << std::setw(4) << std::setfill('0') << c.ethertype << std::dec << std::setfill(' ') << " uid=" << std::setw(5) << c.self_uid @@ -225,7 +296,7 @@ void Tui::render_tui(const Switch& sw, double dt_s) { m_lines_rendered = new_lines; } -void Tui::render_headless(const Switch& sw, double dt_s, uint64_t now_ms) { +void Tui::render_headless(const Switch& sw, double /*dt_s*/, uint64_t now_ms) { char ts[16]; time_t sec = now_ms / 1000; struct tm tm_local; @@ -236,18 +307,13 @@ void Tui::render_headless(const Switch& sw, double dt_s, uint64_t now_ms) { uint64_t total_drops = 0; for (const auto& c : conns) total_drops += c.drops; - double fps[5]; - for (int i = 0; i < 5; ++i) { - uint64_t df = sw.stats().frames_in[i] - m_prev_frames[i]; - fps[i] = df / dt_s; - } - std::cout << "[" << ts << "]" << " conns=" << conns.size() - << " audio=" << std::fixed << std::setprecision(1) << fps[0] << "/s" - << " disco=" << fps[1] << "/s" - << " control=" << fps[2] << "/s" - << " sync=" << fps[3] << "/s" + << " inspectors=" << sw.inspector_count() + << " audio=" << std::fixed << std::setprecision(1) << m_frame_rate[0].smoothed << "/s" + << " disco=" << m_frame_rate[1].smoothed << "/s" + << " control=" << m_frame_rate[2].smoothed << "/s" + << " sync=" << m_frame_rate[3].smoothed << "/s" << " drops=" << total_drops << "\n" << std::flush; } diff --git a/tools/sim_switch/Tui.h b/tools/sim_switch/Tui.h index ea065f1..b662371 100644 --- a/tools/sim_switch/Tui.h +++ b/tools/sim_switch/Tui.h @@ -3,10 +3,24 @@ #include #include +#include #include class Switch; +// Exponentially-weighted moving average — smooths the wildly bursty +// instantaneous rate readings (disco at 5-sec intervals would otherwise +// oscillate between 20/s and 0/s, etc.). α=0.15 gives a ~1-second effective +// window at the 200ms TUI refresh cadence. +struct Ewma { + double smoothed{0.0}; + bool seeded{false}; + void update(double instant, double alpha = 0.15) { + if (!seeded) { smoothed = instant; seeded = true; return; } + smoothed = alpha * instant + (1.0 - alpha) * smoothed; + } +}; + class Tui { public: explicit Tui(std::string socket_path, bool headless); @@ -25,6 +39,15 @@ class Tui { void erase_previous(); void emit_line(const std::string& line); + // Per-peer tx/rx snapshots so we can compute deltas frame-over-frame + // without making the Switch keep a second copy. + struct PeerSnapshot { + uint64_t tx[5]{}; + uint64_t rx[5]{}; + Ewma tx_rate[5]{}; + Ewma rx_rate[5]{}; + }; + std::string m_socket_path; bool m_headless; @@ -34,6 +57,13 @@ class Tui { uint64_t m_prev_bcast[5]{}; uint64_t m_prev_now_ms{0}; + // Smoothed totals (per EtherType). + Ewma m_frame_rate[5]{}; + Ewma m_byte_rate[5]{}; + + // Per-peer snapshots keyed by uid. Pruned when the switch drops them. + std::unordered_map m_peer_snaps; + // For non-flicker redraw — track the previous render's line count. int m_lines_rendered{0}; diff --git a/tools/sim_switch/main.cpp b/tools/sim_switch/main.cpp index 94bd246..edd2beb 100644 --- a/tools/sim_switch/main.cpp +++ b/tools/sim_switch/main.cpp @@ -208,8 +208,10 @@ int main(int argc, char** argv) { } } - // Periodic housekeeping: prune stale disco entries, refresh TUI. + // Periodic housekeeping: prune stale disco entries + peer stats, + // refresh TUI. sw.prune_disco(t, 20000); + sw.prune_peer_stats(t, 30000); tui.refresh(sw, t); } diff --git a/tools/sim_switch/sim_proto.h b/tools/sim_switch/sim_proto.h index 9da6404..853c050 100644 --- a/tools/sim_switch/sim_proto.h +++ b/tools/sim_switch/sim_proto.h @@ -10,9 +10,15 @@ // NOTE: this is NOT part of the OAN raw-Ethernet wire contract. Embedded // firmware never builds this; it exists purely so dev hosts (macOS/Linux) // can run multi-process OALS over AF_UNIX without raw sockets. +// +// v2: SimFrame gained src_uid (switch-populated); SimHello._reserved became +// flags so clients can opt into promiscuous mirroring (used by oaninspect). constexpr uint32_t SIM_MAGIC = 0x4F535354; // 'OSST' -constexpr uint8_t SIM_VERSION = 1; +constexpr uint8_t SIM_VERSION = 2; + +// SimHello.flags bits. +constexpr uint16_t SIM_HELLO_PROMISCUOUS = 0x0001; struct SimHello { uint32_t magic; @@ -20,13 +26,15 @@ struct SimHello { uint8_t _pad; // reserved, must be 0 uint16_t ethertype; uint16_t self_uid; - uint16_t _reserved; // reserved, must be 0 + uint16_t flags; // SIM_HELLO_* bits } __attribute__((packed)); struct SimFrame { uint32_t payload_len; uint16_t ethertype; uint16_t dest_uid; + uint16_t src_uid; // populated by switch from sender's hello + uint16_t _pad; // alignment / future flags } __attribute__((packed)); #endif diff --git a/tools/sim_switch/test/test_sim_switch.cpp b/tools/sim_switch/test/test_sim_switch.cpp index e9f5c21..90d8928 100644 --- a/tools/sim_switch/test/test_sim_switch.cpp +++ b/tools/sim_switch/test/test_sim_switch.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -105,17 +106,48 @@ class RawClient { } ~RawClient() { if (m_fd >= 0) ::close(m_fd); } - bool send_hello(uint32_t magic, uint16_t etype, uint16_t uid) { - SimHello h{magic, SIM_VERSION, 0, etype, uid, 0}; + bool send_hello(uint32_t magic, uint16_t etype, uint16_t uid, + uint16_t flags = 0) { + SimHello h{magic, SIM_VERSION, 0, etype, uid, flags}; return ::send(m_fd, &h, sizeof(h), 0) == (ssize_t)sizeof(h); } bool send_frame(uint16_t etype, uint16_t dest_uid, const std::vector& payload) { - SimFrame f{(uint32_t)payload.size(), etype, dest_uid}; + // v2: SimFrame is 12B with src_uid + _pad. Sender writes 0 — switch + // overwrites src_uid from our hello uid. + SimFrame f{(uint32_t)payload.size(), etype, dest_uid, 0, 0}; ::send(m_fd, &f, sizeof(f), 0); return ::send(m_fd, payload.data(), payload.size(), 0) == (ssize_t)payload.size(); } + // For tests that need the SimFrame header back too (e.g. src_uid check). + struct FrameRead { + bool ok{false}; + SimFrame hdr{}; + std::vector body; + }; + FrameRead read_frame_full(int timeout_ms) { + FrameRead r; + pollfd p{m_fd, POLLIN, 0}; + int pr = ::poll(&p, 1, timeout_ms); + if (pr <= 0) return r; + size_t got = 0; + while (got < sizeof(r.hdr)) { + ssize_t n = ::read(m_fd, reinterpret_cast(&r.hdr) + got, + sizeof(r.hdr) - got); + if (n <= 0) return r; + got += n; + } + r.body.resize(r.hdr.payload_len); + got = 0; + while (got < r.hdr.payload_len) { + ssize_t n = ::read(m_fd, r.body.data() + got, r.hdr.payload_len - got); + if (n <= 0) return r; + got += n; + } + r.ok = true; + return r; + } // Block up to timeout_ms reading one full frame. Returns payload bytes (or empty on timeout/error). std::vector read_frame(int timeout_ms) { pollfd p{m_fd, POLLIN, 0}; @@ -319,3 +351,283 @@ TEST(SimSwitch, SimTransportInterop) { EXPECT_EQ(got, (int)payload.size()); EXPECT_EQ(buf, payload); } + +// ------ M5 ---------------------------------------------------------------- + +// 8. Promiscuous client receives broadcasts on every EtherType regardless of +// its own hello ethertype. +TEST(SimSwitch, PromiscuousReceivesAllEthertypes) { + auto path = make_test_socket_path(); + DaemonProc d(path); + ASSERT_TRUE(d.ready()); + RawClient a, b, c, insp; + ASSERT_TRUE(a.connect(path)); + ASSERT_TRUE(b.connect(path)); + ASSERT_TRUE(c.connect(path)); + ASSERT_TRUE(insp.connect(path)); + a.send_hello(SIM_MAGIC, ETH_PROTO_OANAUDIO, 100); + b.send_hello(SIM_MAGIC, ETH_PROTO_OANCONTROL, 42); + c.send_hello(SIM_MAGIC, ETH_PROTO_OANSYNC, 1); + insp.send_hello(SIM_MAGIC, 0, 0xFFFE, SIM_HELLO_PROMISCUOUS); + std::this_thread::sleep_for(50ms); + + a.send_frame(ETH_PROTO_OANAUDIO, 0, std::vector(50, 0x11)); + b.send_frame(ETH_PROTO_OANCONTROL, 0, std::vector(50, 0x22)); + c.send_frame(ETH_PROTO_OANSYNC, 0, std::vector(50, 0x33)); + + std::map seen_by_etype; + for (int i = 0; i < 3; ++i) { + auto f = insp.read_frame_full(500); + ASSERT_TRUE(f.ok) << "inspector missed frame " << i; + seen_by_etype[f.hdr.ethertype]++; + } + EXPECT_EQ(seen_by_etype[ETH_PROTO_OANAUDIO], 1); + EXPECT_EQ(seen_by_etype[ETH_PROTO_OANCONTROL], 1); + EXPECT_EQ(seen_by_etype[ETH_PROTO_OANSYNC], 1); +} + +// 9. Promiscuous client also receives unicasts to other peers' uids. +TEST(SimSwitch, PromiscuousReceivesUnicasts) { + 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.send_hello(SIM_MAGIC, ETH_PROTO_OANCONTROL, 100); + b.send_hello(SIM_MAGIC, ETH_PROTO_OANCONTROL, 200); + insp.send_hello(SIM_MAGIC, 0, 0xFFFE, SIM_HELLO_PROMISCUOUS); + std::this_thread::sleep_for(50ms); + + std::vector payload(64, 0xAB); + ASSERT_TRUE(a.send_frame(ETH_PROTO_OANCONTROL, 200, payload)); + + auto got_b = b.read_frame_full(500); + ASSERT_TRUE(got_b.ok); + EXPECT_EQ(got_b.body, payload); + EXPECT_EQ(got_b.hdr.dest_uid, 200); + + auto got_i = insp.read_frame_full(500); + ASSERT_TRUE(got_i.ok); + EXPECT_EQ(got_i.body, payload); + EXPECT_EQ(got_i.hdr.dest_uid, 200); + EXPECT_EQ(got_i.hdr.ethertype, ETH_PROTO_OANCONTROL); + + // And to a non-existent dst: only inspector should see it. + std::vector orphan(32, 0xEE); + ASSERT_TRUE(a.send_frame(ETH_PROTO_OANCONTROL, 999, orphan)); + auto got_b2 = b.read_frame_full(100); + EXPECT_FALSE(got_b2.ok); + auto got_i2 = insp.read_frame_full(500); + ASSERT_TRUE(got_i2.ok); + EXPECT_EQ(got_i2.body, orphan); + EXPECT_EQ(got_i2.hdr.dest_uid, 999); +} + +// 10. Switch overwrites SimFrame::src_uid with the sender's hello uid, even +// if the sender wrote a different value. Validated via inspector readback. +TEST(SimSwitch, SrcUidPopulatedBySwitch) { + auto path = make_test_socket_path(); + DaemonProc d(path); + ASSERT_TRUE(d.ready()); + RawClient a, insp; + ASSERT_TRUE(a.connect(path)); + ASSERT_TRUE(insp.connect(path)); + a.send_hello(SIM_MAGIC, ETH_PROTO_OANDISCO, 77); + insp.send_hello(SIM_MAGIC, 0, 0xFFFE, SIM_HELLO_PROMISCUOUS); + std::this_thread::sleep_for(50ms); + + // Sender writes a lying src_uid by going lower-level than send_frame: + SimFrame f{16, ETH_PROTO_OANDISCO, 0, /*src_uid LIE=*/0xDEAD, 0}; + ::send(a.fd(), &f, sizeof(f), 0); + std::vector body(16, 0x55); + ::send(a.fd(), body.data(), body.size(), 0); + + auto got = insp.read_frame_full(500); + ASSERT_TRUE(got.ok); + EXPECT_EQ(got.hdr.src_uid, 77); // switch overwrote the lie + EXPECT_EQ(got.hdr.dest_uid, 0); + EXPECT_EQ(got.body, body); +} + +// 11. Multiple inspectors each see the same frame. +TEST(SimSwitch, MultipleInspectors) { + auto path = make_test_socket_path(); + DaemonProc d(path); + ASSERT_TRUE(d.ready()); + RawClient a, i1, i2; + ASSERT_TRUE(a.connect(path)); + ASSERT_TRUE(i1.connect(path)); + ASSERT_TRUE(i2.connect(path)); + a.send_hello(SIM_MAGIC, ETH_PROTO_OANDISCO, 5); + i1.send_hello(SIM_MAGIC, 0, 0xFFFE, SIM_HELLO_PROMISCUOUS); + i2.send_hello(SIM_MAGIC, 0, 0xFFFF, SIM_HELLO_PROMISCUOUS); + std::this_thread::sleep_for(50ms); + + std::vector payload(40, 0x42); + ASSERT_TRUE(a.send_frame(ETH_PROTO_OANDISCO, 0, payload)); + + auto g1 = i1.read_frame_full(500); + auto g2 = i2.read_frame_full(500); + ASSERT_TRUE(g1.ok); + ASSERT_TRUE(g2.ok); + EXPECT_EQ(g1.body, payload); + EXPECT_EQ(g2.body, payload); +} + +// 12. A promiscuous client does NOT receive its own outgoing frames mirrored. +TEST(SimSwitch, PromiscuousNoLoopback) { + auto path = make_test_socket_path(); + DaemonProc d(path); + ASSERT_TRUE(d.ready()); + RawClient insp, peer; + ASSERT_TRUE(insp.connect(path)); + ASSERT_TRUE(peer.connect(path)); + insp.send_hello(SIM_MAGIC, ETH_PROTO_OANDISCO, 0xFFFE, SIM_HELLO_PROMISCUOUS); + peer.send_hello(SIM_MAGIC, ETH_PROTO_OANDISCO, 50); + std::this_thread::sleep_for(50ms); + + std::vector payload(16, 0xCC); + ASSERT_TRUE(insp.send_frame(ETH_PROTO_OANDISCO, 0, payload)); + + // Peer is on disco ethertype → it should receive the broadcast. + auto got_peer = peer.read_frame_full(500); + ASSERT_TRUE(got_peer.ok); + EXPECT_EQ(got_peer.body, payload); + + // Inspector should NOT receive its own broadcast back. + auto got_self = insp.read_frame_full(200); + EXPECT_FALSE(got_self.ok); +} + +// 13. Promiscuous uid does not steal unicast traffic from a real peer with +// the same uid. (Edge case: an inspector that happens to register the +// same uid as a normal peer must not redirect that peer's unicasts.) +TEST(SimSwitch, PromiscuousNotInRouteTable) { + 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)); + + // Inspector registers first, claiming uid=200 with promiscuous flag. + insp.send_hello(SIM_MAGIC, ETH_PROTO_OANCONTROL, 200, SIM_HELLO_PROMISCUOUS); + a.send_hello(SIM_MAGIC, ETH_PROTO_OANCONTROL, 100); + b.send_hello(SIM_MAGIC, ETH_PROTO_OANCONTROL, 200); // same uid as inspector + std::this_thread::sleep_for(50ms); + + std::vector payload(20, 0xAA); + ASSERT_TRUE(a.send_frame(ETH_PROTO_OANCONTROL, 200, payload)); + + // The real peer B must receive the unicast (the inspector must not steal it). + auto got_b = b.read_frame_full(500); + ASSERT_TRUE(got_b.ok); + EXPECT_EQ(got_b.body, payload); + EXPECT_EQ(got_b.hdr.dest_uid, 200); + + // Inspector also receives it via the mirror pass. + auto got_i = insp.read_frame_full(500); + ASSERT_TRUE(got_i.ok); + EXPECT_EQ(got_i.body, payload); +} + +// 14. Wire version mismatch is rejected: v1 hello (older SIM_VERSION value) +// causes the daemon to close. +TEST(SimSwitch, V1HelloRejected) { + auto path = make_test_socket_path(); + DaemonProc d(path); + ASSERT_TRUE(d.ready()); + RawClient a; + ASSERT_TRUE(a.connect(path)); + + // Construct a v1 hello (8 bytes: magic+v1+pad+etype+uid+reserved). + // Sending the v2 hello with version=1 reaches the same parse path. + SimHello h{SIM_MAGIC, /*version=*/1, 0, ETH_PROTO_OANAUDIO, 1, 0}; + ASSERT_EQ(::send(a.fd(), &h, sizeof(h), 0), (ssize_t)sizeof(h)); + + pollfd p{a.fd(), POLLIN, 0}; + int pr = ::poll(&p, 1, 500); + ASSERT_GT(pr, 0); + char buf; + EXPECT_EQ(::read(a.fd(), &buf, 1), 0); // EOF from server +} + +// ------ Filter parser unit tests ------------------------------------------ +// These exercise the oaninspect filter expression parser as a pure unit, +// no daemon/process involved. + +#include "../../oaninspect/Filter.h" + +TEST(InspectFilter, EmptyExprMatchesAll) { + Filter f; std::string err; + EXPECT_TRUE(f.parse("", err)); + EXPECT_TRUE(f.empty()); + EXPECT_TRUE(f.match(0, 1, 2)); + EXPECT_TRUE(f.match(4, 0, 0)); +} + +TEST(InspectFilter, EthertypeMaskAndOr) { + Filter f; std::string err; + ASSERT_TRUE(f.parse("ethertype=disco,control", err)); + EXPECT_FALSE(f.match(0, 1, 2)); // audio rejected + EXPECT_TRUE(f.match(1, 1, 2)); // disco + EXPECT_TRUE(f.match(2, 1, 2)); // control + EXPECT_FALSE(f.match(3, 1, 2)); // sync rejected +} + +TEST(InspectFilter, PeerMatchesEitherSide) { + Filter f; std::string err; + ASSERT_TRUE(f.parse("peer=42", err)); + EXPECT_TRUE(f.match(0, 42, 99)); + EXPECT_TRUE(f.match(0, 99, 42)); + EXPECT_FALSE(f.match(0, 99, 100)); +} + +TEST(InspectFilter, MixedConditions) { + Filter f; std::string err; + ASSERT_TRUE(f.parse("ethertype=audio,src=51", err)); + EXPECT_TRUE(f.match(0, 51, 100)); // audio + src=51 + EXPECT_FALSE(f.match(0, 52, 100)); // audio but wrong src + EXPECT_FALSE(f.match(2, 51, 100)); // src=51 but wrong ethertype +} + +TEST(InspectFilter, BadKeyReportsError) { + Filter f; std::string err; + EXPECT_FALSE(f.parse("nope=1", err)); + EXPECT_NE(err.find("unknown filter key"), std::string::npos); +} + +TEST(InspectFilter, BadEthertypeReportsError) { + Filter f; std::string err; + EXPECT_FALSE(f.parse("ethertype=quack", err)); + EXPECT_NE(err.find("unknown ethertype"), std::string::npos); +} + +// ------ EWMA unit test ---------------------------------------------------- +#include "../Tui.h" + +TEST(Ewma, SmoothsAlternatingInput) { + Ewma e; + // Push alternating 1/0 with default α=0.15. After ~20 samples the + // smoothed value should be near 0.5 ± small. + for (int i = 0; i < 200; ++i) { + e.update(i % 2 ? 1.0 : 0.0); + } + EXPECT_GT(e.smoothed, 0.35); + EXPECT_LT(e.smoothed, 0.65); +} + +TEST(Ewma, SeedsFromFirstSample) { + Ewma e; + e.update(42.0); + EXPECT_DOUBLE_EQ(e.smoothed, 42.0); +} + +TEST(Ewma, ConvergesToConstant) { + Ewma e; + for (int i = 0; i < 100; ++i) e.update(7.0); + EXPECT_NEAR(e.smoothed, 7.0, 0.01); +} From 27ef9c7de10dda16548fda7052e54d98a7d78099 Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Wed, 3 Jun 2026 22:40:42 +0200 Subject: [PATCH 07/15] Stop RT threads busy-spinning on host backends (M6 of dev-tooling plan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OALSEngine idle on macOS was burning ~150 % CPU (and ~350 % with audio flowing) because three loops weren't actually paced: - main thread: while(true) NetMan::update_netman() — the function body is empty. Pure busy spin, one full core. - pipe_updater: precise_sleep_ns(100) between update_processes() walks of all 64 disabled pipes. nanosleep floor on macOS is ~3 µs, so it ticked at ~300 kHz. - clock_syncer: ClockMaster::sync_process() does an async recv that returns 0 on EAGAIN; the 10 µs precise_sleep_ns between calls couldn't keep up. io_sim's clock_thread had the same async-recv spin with no sleep at all. The four real RT recv threads (audiopoll, controlpoll, packet_receiver) were already cold — they call receive_data(async=false) which blocks in the kernel correctly via SimTransport::recv's existing poll(-1). All four fixes are gated by OAN_HOST_BACKENDS so the Linux RT path is byte-identical. AudioEngine gains a CV pair (notify_block_ready / wait_for_block); feed_pipe signals it from the audio recv callback so pipe_updater can block instead of ticking. NetMan gains clock_wait_or_tick(timeout_ms) that drains the sync poll-spin via ClockMaster::wait_sync_readable before running the 1 s heartbeat. Main thread parks on pause(). io_sim's clock_thread does the same wait_sync_readable wrap. Measured on macOS, OALSEngine: - idle (no io_sim): 151 % → 0.9 % - with io_sim audio flowing: ~350 % → 6.0 % Audio throughput preserved (~3960 audio frames/s on the switch). All 23 sim_switch tests still green. --- OpenAudioNetwork | 2 +- engine/AudioEngine.cpp | 26 ++++++++++++++++++++++++++ engine/AudioEngine.h | 26 ++++++++++++++++++++++++++ engine/NetMan.cpp | 12 ++++++++++++ engine/NetMan.h | 9 +++++++++ engine/main.cpp | 32 ++++++++++++++++++++++++++++++++ io_sim/main.cpp | 5 +++++ 7 files changed, 111 insertions(+), 1 deletion(-) diff --git a/OpenAudioNetwork b/OpenAudioNetwork index 5c74aa9..5e21675 160000 --- a/OpenAudioNetwork +++ b/OpenAudioNetwork @@ -1 +1 @@ -Subproject commit 5c74aa919a390446d8e67ae3b065fec7629b692d +Subproject commit 5e21675d33252ea1004bb3a122a47da7447a6c71 diff --git a/engine/AudioEngine.cpp b/engine/AudioEngine.cpp index 642b896..e6ebd4c 100644 --- a/engine/AudioEngine.cpp +++ b/engine/AudioEngine.cpp @@ -5,6 +5,10 @@ #include "AudioEngine.h" +#ifdef OAN_HOST_BACKENDS +#include +#endif + AudioEngine::AudioEngine() { } @@ -26,6 +30,9 @@ void AudioEngine::feed_pipe(AudioPacket &packet) { pipe->push_packet(packet); } } +#ifdef OAN_HOST_BACKENDS + notify_block_ready(); +#endif } std::optional AudioEngine::install_pipe(std::shared_ptr audio_pipe) { @@ -99,3 +106,22 @@ void AudioEngine::update_processes() { } } +#ifdef OAN_HOST_BACKENDS +void AudioEngine::notify_block_ready() { + { + std::lock_guard lk{m_wakeup_mtx}; + m_block_ready = true; + } + m_wakeup_cv.notify_one(); +} + +bool AudioEngine::wait_for_block(int timeout_us) { + std::unique_lock lk{m_wakeup_mtx}; + bool woken = m_wakeup_cv.wait_for( + lk, std::chrono::microseconds(timeout_us), + [this]() { return m_block_ready; }); + m_block_ready = false; + return woken; +} +#endif + diff --git a/engine/AudioEngine.h b/engine/AudioEngine.h index 86ae993..2fc2bd0 100644 --- a/engine/AudioEngine.h +++ b/engine/AudioEngine.h @@ -13,6 +13,11 @@ #include #include +#ifdef OAN_HOST_BACKENDS +#include +#include +#endif + #include "plugins/loader/AudioPipe.h" #include "engine/piping/io/AudioInPipe.h" #include "OpenAudioNetwork/common/packet_structs.h" @@ -39,8 +44,29 @@ class AudioEngine { bool reset_pipes(); uint64_t get_channel_usage_map(); + +#ifdef OAN_HOST_BACKENDS + // Producer side: called from the audio recv callback after feed_pipe. + // Wakes the pipe_updater thread so it can drain the block instead of + // busy-ticking. No-op on Linux — Linux RT thread layout is unchanged. + void notify_block_ready(); + + // Consumer side: pipe_updater blocks here until a block arrives or + // the timeout elapses. Timeout acts as a heartbeat so time-driven + // work in continuous_process() (release envelopes, etc.) still runs + // when the wire is idle. Returns true if woken by notify, false on + // timeout. timeout_us = 0 means no wait. + bool wait_for_block(int timeout_us); +#endif + private: std::array, AUDIO_ENGINE_MAX_PIPES> m_pipes; + +#ifdef OAN_HOST_BACKENDS + std::mutex m_wakeup_mtx; + std::condition_variable m_wakeup_cv; + bool m_block_ready{false}; +#endif }; #endif //AUDIOENGINE_H diff --git a/engine/NetMan.cpp b/engine/NetMan.cpp index 8857e3d..354114e 100644 --- a/engine/NetMan.cpp +++ b/engine/NetMan.cpp @@ -61,6 +61,18 @@ void NetMan::update_self_topo(NodeTopology new_topo) { m_nmapper->update_resource_mapping(new_topo); } +#ifdef OAN_HOST_BACKENDS +void NetMan::clock_wait_or_tick(int timeout_ms) { + // Drain the sync poll-spin: block until the sync socket has data + // (or timeout), then let clock_master_process handle the 1 s + // heartbeat + recv. Result is ignored — recv inside + // clock_master_process is still non-blocking and returns 0 cleanly + // on spurious wakeups / timeouts. + m_cm->wait_sync_readable(timeout_ms); + clock_master_process(); +} +#endif + void NetMan::clock_master_process() { constexpr uint64_t sync_interval = 1000000; static uint64_t last_sync = NetworkMapper::local_now_us(); diff --git a/engine/NetMan.h b/engine/NetMan.h index e3763a7..9df9596 100644 --- a/engine/NetMan.h +++ b/engine/NetMan.h @@ -33,6 +33,15 @@ class NetMan { std::shared_ptr get_net_mapper(); void clock_master_process(); + +#ifdef OAN_HOST_BACKENDS + // Wait up to timeout_ms for a sync packet to arrive, then run the + // 1 s heartbeat tick. Replaces the busy-loop on the engine's + // clock_syncer thread when running over host backends — the Linux + // RT path keeps using clock_master_process directly. + void clock_wait_or_tick(int timeout_ms); +#endif + private: std::shared_ptr m_nmapper; PeerConf m_pconf; diff --git a/engine/main.cpp b/engine/main.cpp index 4cb5959..4e1ad5c 100644 --- a/engine/main.cpp +++ b/engine/main.cpp @@ -7,6 +7,10 @@ #include #include +#ifdef OAN_HOST_BACKENDS +#include // pause() +#endif + #include "AudioEngine.h" #include "log.h" #include "NetMan.h" @@ -124,6 +128,16 @@ int main(int argc, char* argv[]) { oals::rt::set_running_cpu(2); while (true) { +#ifdef OAN_HOST_BACKENDS + // Block until the audio recv callback signals a new block, or + // until the heartbeat timeout (lets continuous_process keep + // running time-driven work — release envelopes etc. — when + // the wire is idle). 1 ms is well below the 667 µs block + // period at 96 kHz so it can never delay a real block. + audio_engine.wait_for_block(1000); + router.poll_local_audio_buffer(); + audio_engine.update_processes(); +#else router.poll_local_audio_buffer(); audio_engine.update_processes(); @@ -131,6 +145,7 @@ int main(int argc, char* argv[]) { // It is a blocking task, to let the other threads run // I must add a small wait here oals::rt::precise_sleep_ns(100); +#endif } }); @@ -138,8 +153,15 @@ int main(int argc, char* argv[]) { oals::rt::set_running_cpu(3); while (true) { +#ifdef OAN_HOST_BACKENDS + // Block in poll() up to 200 ms for sync recv. clock_master_process + // self-paces its 1 s broadcast heartbeat internally, so the + // 200 ms wake cadence is more than enough to keep it firing. + nman.clock_wait_or_tick(200); +#else nman.clock_master_process(); oals::rt::precise_sleep_ns(10000); +#endif } }); @@ -148,9 +170,19 @@ int main(int argc, char* argv[]) { pipe_updater.detach(); clock_syncer.detach(); +#ifdef OAN_HOST_BACKENDS + // The detached worker threads do all the real work; main has nothing + // to do but stay alive until SIGTERM/SIGINT. update_netman() is empty + // today, so the Linux while-loop below is a CPU-melting no-op on Mac. + // Park on pause() instead — Ctrl-C / kill still ends the process. + while (true) { + pause(); + } +#else while (true) { nman.update_netman(); } +#endif return 0; } \ No newline at end of file diff --git a/io_sim/main.cpp b/io_sim/main.cpp index 40c1956..691169e 100644 --- a/io_sim/main.cpp +++ b/io_sim/main.cpp @@ -203,6 +203,11 @@ int main(int argc, char* argv[]) { std::thread clock_thread = std::thread([&cs]() { while (true) { +#ifdef OAN_HOST_BACKENDS + // Pace the otherwise-tight async recv with a poll() timeout + // so we don't burn a core spinning on EAGAIN. + cs.wait_sync_readable(200); +#endif cs.sync_process(); } }); From 458d1c5d4835e4e342f3a57b647723d66f296afb Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Thu, 4 Jun 2026 00:22:26 +0200 Subject: [PATCH 08/15] Add --help on engine + io_sim, scripts/dev-up.sh, bump OAN for M7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OALSEngine and io_simulator now print a real usage block on --help instead of trying to parse the flag as an interface name. Both explain the sim: transport spec inline so contributors don't need to dig in CLAUDE.md. scripts/dev-up.sh launches the 3-process stack (sim_switch + OALSEngine + io_simulator, optionally + oaninspect) in a tmux session. Idempotent: --down kills the session and clears the socket; re-running cleans up stale processes first. Per-pane logs tee'd to /tmp/osst-dev-.log. OAN bump pulls in the M7 Mac RT shim (THREAD_TIME_CONSTRAINT_POLICY + mlockall). No engine call site change — set_thread_realtime now actually applies a budget on Mac instead of being a no-op. --- OpenAudioNetwork | 2 +- engine/main.cpp | 35 ++++++++++++--- io_sim/main.cpp | 35 ++++++++++++--- scripts/dev-up.sh | 109 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 13 deletions(-) create mode 100755 scripts/dev-up.sh diff --git a/OpenAudioNetwork b/OpenAudioNetwork index 5e21675..dbfd5b4 160000 --- a/OpenAudioNetwork +++ b/OpenAudioNetwork @@ -1 +1 @@ -Subproject commit 5e21675d33252ea1004bb3a122a47da7447a6c71 +Subproject commit dbfd5b4d8c089c34524c3059d1900eb5f7df1475 diff --git a/engine/main.cpp b/engine/main.cpp index 4e1ad5c..8493f44 100644 --- a/engine/main.cpp +++ b/engine/main.cpp @@ -25,15 +25,38 @@ #include "OpenAudioNetwork/netutils/platform/rt.h" -int main(int argc, char* argv[]) { - - /* - * Param structure : ./oalsengine eth_iface - */ +static void print_usage() { + std::cout << + "OALSEngine — Open Audio Live System DSP engine\n" + "\n" + "Usage: OALSEngine \n" + "\n" + " Network interface or transport spec to bind on.\n" + " Linux production: an L2 interface name (e.g. eth0, lo).\n" + " Host dev (macOS / Linux with OAN_HOST_BACKENDS=ON):\n" + " sim: connect to sim_switch via\n" + " /tmp/osst-sim-.sock\n" + " sim:,mac=02:..:01 override the locally-administered MAC\n" + " raw: (Mac BPF — not yet implemented)\n" + " Defaults to \"lo\" when omitted.\n" + "\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" + "be co-located with at least one peer (UI / IO board / io_simulator)\n" + "on the same L2 segment.\n"; +} +int main(int argc, char* argv[]) { std::string eth_interface = "lo"; if (argc > 1) { - eth_interface = std::string(argv[1]); + std::string arg = argv[1]; + if (arg == "--help" || arg == "-h") { + print_usage(); + return 0; + } + eth_interface = std::move(arg); } AudioPlumber plumber{}; diff --git a/io_sim/main.cpp b/io_sim/main.cpp index 691169e..3f06f92 100644 --- a/io_sim/main.cpp +++ b/io_sim/main.cpp @@ -132,14 +132,37 @@ std::vector gen_packet_strm_tone(float freq_hz, float gain, int cha return stream_packets; } +static void print_usage() { + std::cout << + "io_simulator — looping audio source for the OALS dev stack\n" + "\n" + "Usage: io_simulator [config-path]\n" + "\n" + " Network interface or transport spec, same as OALSEngine.\n" + " Linux: an L2 ifname. Host dev: sim:.\n" + " Defaults to \"virbr0\" when omitted.\n" + " [config-path] Path to the io_sim JSON track config.\n" + " Defaults to ./io_sim.json. Example template in\n" + " io_sim/io_sim.example.json.\n" + "\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"; +} + int main(int argc, char* argv[]) { - std::cout << "OpenAudioLive IO Emulator" << std::endl; + if (argc > 1) { + std::string a = argv[1]; + if (a == "--help" || a == "-h") { + print_usage(); + return 0; + } + } - /* - * Param structure : ./io_simulator [config_path] - * eth_iface may be a transport prefix (sim:default, raw:en0) on host dev. - * config_path defaults to ./io_sim.json. - */ + std::cout << "OpenAudioLive IO Emulator" << std::endl; const std::string config_path = (argc > 2) ? argv[2] : "io_sim.json"; diff --git a/scripts/dev-up.sh b/scripts/dev-up.sh new file mode 100755 index 0000000..8fac0c8 --- /dev/null +++ b/scripts/dev-up.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Launch the three-process OALS dev stack (sim_switch + OALSEngine + io_simulator) +# in a tmux session. Idempotent: kills any previous session, removes the socket, +# re-runs. Per-pane logs land in /tmp/osst-dev-.log. +# +# Usage: +# scripts/dev-up.sh # default: sim:default, io_sim.example.json +# scripts/dev-up.sh --name foo # use sim:foo (separate socket) +# scripts/dev-up.sh --config path.json # io_sim config +# scripts/dev-up.sh --inspect # launch oaninspect as a 4th pane +# scripts/dev-up.sh --build-dir build # default ./build +# scripts/dev-up.sh --down # kill the session and exit + +set -euo pipefail + +NAME="default" +CONFIG="" +BUILD_DIR="build" +ATTACH=1 +INSPECT=0 +DOWN=0 +SESSION="osst-dev" + +while [[ $# -gt 0 ]]; do + case "$1" in + --name) NAME="$2"; shift 2 ;; + --config) CONFIG="$2"; shift 2 ;; + --build-dir) BUILD_DIR="$2"; shift 2 ;; + --inspect) INSPECT=1; shift ;; + --no-attach) ATTACH=0; shift ;; + --down) DOWN=1; shift ;; + -h|--help) + sed -n '2,/^set -euo/p' "$0" | sed 's/^# //; s/^#//' + exit 0 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +cd "$(dirname "$0")/.." +REPO_ROOT="$(pwd)" + +SOCK="/tmp/osst-sim-${NAME}.sock" +[[ -z "$CONFIG" ]] && CONFIG="$REPO_ROOT/io_sim/io_sim.example.json" + +if ! command -v tmux >/dev/null; then + echo "dev-up: tmux is required (brew install tmux)" >&2 + exit 1 +fi + +# Always start clean: kill the previous session and stray binaries from a +# crashed run. Leaving a previous OALSEngine alive would silently bind to +# our socket name and the new one would log "connect failed". +tmux kill-session -t "$SESSION" 2>/dev/null || true +pkill -x sim_switch 2>/dev/null || true +pkill -x OALSEngine 2>/dev/null || true +pkill -x io_simulator 2>/dev/null || true +pkill -x oaninspect 2>/dev/null || true +rm -f "$SOCK" + +if [[ "$DOWN" == "1" ]]; then + echo "dev-up: stack down (session ${SESSION} killed, socket removed)" + exit 0 +fi + +SWITCH_BIN="$REPO_ROOT/$BUILD_DIR/tools/sim_switch/sim_switch" +ENGINE_BIN="$REPO_ROOT/$BUILD_DIR/engine/OALSEngine" +IOSIM_BIN="$REPO_ROOT/$BUILD_DIR/io_sim/io_simulator" +INSPECT_BIN="$REPO_ROOT/$BUILD_DIR/tools/oaninspect/oaninspect" + +for bin in "$SWITCH_BIN" "$ENGINE_BIN" "$IOSIM_BIN"; do + if [[ ! -x "$bin" ]]; then + echo "dev-up: missing $bin — run 'cmake --build $BUILD_DIR' first" >&2 + exit 1 + fi +done + +# Per-pane stdout/stderr also gets tee'd to a file so you can grep later. +LOG_SWITCH="/tmp/osst-dev-switch.log" +LOG_ENGINE="/tmp/osst-dev-engine.log" +LOG_IOSIM="/tmp/osst-dev-iosim.log" +LOG_INSPECT="/tmp/osst-dev-inspect.log" +: > "$LOG_SWITCH" "$LOG_ENGINE" "$LOG_IOSIM" "$LOG_INSPECT" + +CMD_SWITCH="$SWITCH_BIN --socket-path $SOCK 2>&1 | tee $LOG_SWITCH" +CMD_ENGINE="sleep 0.5 && $ENGINE_BIN sim:$NAME 2>&1 | tee $LOG_ENGINE" +CMD_IOSIM="sleep 1.0 && $IOSIM_BIN sim:$NAME $CONFIG 2>&1 | tee $LOG_IOSIM" +CMD_INSPECT="sleep 1.5 && $INSPECT_BIN --socket-path $SOCK --filter ethertype=disco,control,sync 2>&1 | tee $LOG_INSPECT" + +tmux new-session -d -s "$SESSION" -n stack "$CMD_SWITCH" +tmux split-window -t "$SESSION:stack" -v "$CMD_ENGINE" +tmux split-window -t "$SESSION:stack" -h "$CMD_IOSIM" + +if [[ "$INSPECT" == "1" ]]; then + tmux split-window -t "$SESSION:stack.0" -h "$CMD_INSPECT" +fi + +tmux select-layout -t "$SESSION:stack" tiled +tmux set-option -t "$SESSION" mouse on + +echo "dev-up: session '$SESSION' running on socket $SOCK" +echo " switch: $LOG_SWITCH" +echo " engine: $LOG_ENGINE" +echo " iosim: $LOG_IOSIM" +[[ "$INSPECT" == "1" ]] && echo " inspect: $LOG_INSPECT" +echo " detach: Ctrl-b d kill: scripts/dev-up.sh --down" + +if [[ "$ATTACH" == "1" ]]; then + tmux attach -t "$SESSION" +fi From 4041b3953d67212492636e42daa1050138261907 Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Thu, 4 Jun 2026 00:22:35 +0200 Subject: [PATCH 09/15] Add CI, clang-format, pre-commit hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI: GitHub Actions runs on every push and PR to any branch, on ubuntu-latest + macos-latest. Installs cmake, ninja, qt6, libsndfile, gtest from package managers, then configures with OAN_HOST_BACKENDS=ON, builds, and runs ctest. clang-format: LLVM-based, K&R braces, 100-col limit, left-aligned references (auto& is the codebase majority). Editor-on-save only — no mass reformat. pre-commit: clang-format + trailing-whitespace + EOF fixer + merge- conflict + large-file guard. Excludes the OAN submodule (it has its own config). Install via 'pip install pre-commit && pre-commit install'. --- .clang-format | 38 +++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 49 ++++++++++++++++++++++++++++++++++++++++ .pre-commit-config.yaml | 23 +++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 .clang-format create mode 100644 .github/workflows/ci.yml create mode 100644 .pre-commit-config.yaml diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..67ecad0 --- /dev/null +++ b/.clang-format @@ -0,0 +1,38 @@ +# Pinned style for OALS. Tuned to match what's already in the tree +# (4-space indent, K&R braces, ref/pointer attached to the variable, +# no hard column cap). Apply to new code via editor-on-save; do NOT +# mass-format existing files in a single sweep. +--- +BasedOnStyle: LLVM +Language: Cpp +Standard: c++20 + +IndentWidth: 4 +TabWidth: 4 +UseTab: Never +ColumnLimit: 100 + +BreakBeforeBraces: Attach +AllowShortFunctionsOnASingleLine: Empty +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +SpaceAfterCStyleCast: false +PointerAlignment: Left +ReferenceAlignment: Left +DerivePointerAlignment: false + +AccessModifierOffset: -4 +NamespaceIndentation: None +FixNamespaceComments: true + +IncludeBlocks: Preserve +SortIncludes: false + +AlignAfterOpenBracket: Align +BinPackParameters: false +BinPackArguments: true +AllowAllParametersOfDeclarationOnNextLine: true +ConstructorInitializerAllOnOneLineOrOnePerLine: true + +EmptyLineBeforeAccessModifier: LogicalBlock +SeparateDefinitionBlocks: Leave diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1290847 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: ['**'] + pull_request: + branches: ['**'] + +jobs: + build-test: + name: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install deps (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake ninja-build pkg-config \ + qt6-base-dev libsndfile1-dev libgtest-dev + + - name: Install deps (macOS) + if: runner.os == 'macOS' + run: | + brew update + brew install cmake ninja qt6 libsndfile googletest + + - name: Configure + run: | + cmake -G Ninja -B build \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DOAN_HOST_BACKENDS=ON \ + -DCMAKE_PREFIX_PATH="$(brew --prefix qt6 2>/dev/null || echo /usr)" + + - name: Build + run: cmake --build build + + - name: Test + working-directory: build + run: ctest --output-on-failure diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f9a2c09 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +# pre-commit hooks for OALS. Install once per clone: +# pip install pre-commit && pre-commit install +# After that, every commit runs the hooks on staged files only. +repos: + - repo: https://github.com/pre-commit/mirrors-clang-format + rev: v17.0.6 + hooks: + - id: clang-format + types_or: [c++, c] + # Exclude vendored / build trees and the OpenAudioNetwork submodule + # (it has its own .pre-commit-config.yaml and own clang-format + # invocation when committing inside it). + exclude: '^(build/|OpenAudioNetwork/|.*\.kicad_.*)' + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-yaml + - id: check-added-large-files + args: ['--maxkb=512'] From 712d7c52fb4776ee2996442ea043ee5f78d2dbdf Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Thu, 4 Jun 2026 00:28:09 +0200 Subject: [PATCH 10/15] sim_switch_test: link oancommon for NetworkMapper symbol Linux ld is strict about undefined references in shared libs; oannetutils' LowLatSocket pulls in NetworkMapper::get_mac_by_uid which lives in oancommon. macOS' -undefined,dynamic_lookup link option hid this until CI tried a real Linux build. --- tools/sim_switch/CMakeLists.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/sim_switch/CMakeLists.txt b/tools/sim_switch/CMakeLists.txt index 9672073..efba572 100644 --- a/tools/sim_switch/CMakeLists.txt +++ b/tools/sim_switch/CMakeLists.txt @@ -30,6 +30,10 @@ if(OAN_HOST_BACKENDS) target_link_libraries(sim_switch_test PRIVATE GTest::gtest GTest::gtest_main oannetutils + # oannetutils' LowLatSocket calls NetworkMapper::get_mac_by_uid, + # which lives in oancommon. macOS' -undefined,dynamic_lookup + # link option masks this, but Linux ld is strict. + oancommon ) # Tests need the daemon binary in PATH; expose its build location. add_test(NAME sim_switch_smoke COMMAND sim_switch_test) From 96b178c9b51e28bb74303e5ae541b872ec071f85 Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Thu, 4 Jun 2026 00:32:04 +0200 Subject: [PATCH 11/15] Bump OAN for Linux link fix --- OpenAudioNetwork | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenAudioNetwork b/OpenAudioNetwork index dbfd5b4..f0c5701 160000 --- a/OpenAudioNetwork +++ b/OpenAudioNetwork @@ -1 +1 @@ -Subproject commit dbfd5b4d8c089c34524c3059d1900eb5f7df1475 +Subproject commit f0c5701f02c3ccc4f36f43a04f3bb4a98ae53758 From 85cddf13620e0b09fd9f00136236c2638cf28f59 Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Thu, 4 Jun 2026 00:35:18 +0200 Subject: [PATCH 12/15] Bump OAN for -z undefs Linux link fix --- OpenAudioNetwork | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenAudioNetwork b/OpenAudioNetwork index f0c5701..2c387f5 160000 --- a/OpenAudioNetwork +++ b/OpenAudioNetwork @@ -1 +1 @@ -Subproject commit f0c5701f02c3ccc4f36f43a04f3bb4a98ae53758 +Subproject commit 2c387f5505b9ec478841454f8591948fbbedcd57 From d2529c715e49efda012bd989c1076e35d632fe29 Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Thu, 4 Jun 2026 00:42:38 +0200 Subject: [PATCH 13/15] sim_switch_test: --no-as-needed on Linux for transitive OAN link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real root cause of the Linux link failure: GNU ld defaults to --as-needed since binutils 2.31. sim_switch_test's own code doesn't reference any oancommon symbol directly, so the linker would drop liboancommon.so from the link line — and the transitive reference from oannetutils' LowLatSocket::get_mac to NetworkMapper::get_mac_by_uid stayed unresolved. Wrap both OAN .so's in --no-as-needed/--as-needed on Linux so both are pinned in. macOS' ld64 has no --as-needed concept; plain list is fine there. Previous attempts (--unresolved-symbols=ignore-in-shared-libs, then -z undefs at the .so level) only addressed building the .so itself, not the executable link step. OAN submodule bump: just a comment refresh in netutils/CMakeLists. --- OpenAudioNetwork | 2 +- tools/sim_switch/CMakeLists.txt | 26 ++++++++++++++++++-------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/OpenAudioNetwork b/OpenAudioNetwork index 2c387f5..bafa755 160000 --- a/OpenAudioNetwork +++ b/OpenAudioNetwork @@ -1 +1 @@ -Subproject commit 2c387f5505b9ec478841454f8591948fbbedcd57 +Subproject commit bafa7554b8070c22cc64820c87293c4aa1a00b69 diff --git a/tools/sim_switch/CMakeLists.txt b/tools/sim_switch/CMakeLists.txt index efba572..20b17b9 100644 --- a/tools/sim_switch/CMakeLists.txt +++ b/tools/sim_switch/CMakeLists.txt @@ -27,14 +27,24 @@ if(OAN_HOST_BACKENDS) ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_SOURCE_DIR}/tools/oaninspect ) - target_link_libraries(sim_switch_test PRIVATE - GTest::gtest GTest::gtest_main - oannetutils - # oannetutils' LowLatSocket calls NetworkMapper::get_mac_by_uid, - # which lives in oancommon. macOS' -undefined,dynamic_lookup - # link option masks this, but Linux ld is strict. - oancommon - ) + # liboannetutils.so references NetworkMapper symbols from + # liboancommon.so. sim_switch_test itself doesn't directly use + # oancommon, so on Linux ld --as-needed (the default since + # binutils 2.31) would drop oancommon from the link line — and + # the transitive reference from oannetutils stays unresolved. + # --no-as-needed pins both .so's into the binary unconditionally, + # matching macOS' behavior (which has no --as-needed concept). + if(APPLE) + target_link_libraries(sim_switch_test PRIVATE + GTest::gtest GTest::gtest_main + oannetutils oancommon + ) + else() + target_link_libraries(sim_switch_test PRIVATE + GTest::gtest GTest::gtest_main + -Wl,--no-as-needed oannetutils oancommon -Wl,--as-needed + ) + endif() # Tests need the daemon binary in PATH; expose its build location. add_test(NAME sim_switch_smoke COMMAND sim_switch_test) set_tests_properties(sim_switch_smoke PROPERTIES From 4bc09d39d7119f7fdf6086cb8fc0aba93130e9b0 Mon Sep 17 00:00:00 2001 From: "jonathan.reichardt" Date: Thu, 4 Jun 2026 02:52:50 +0200 Subject: [PATCH 14/15] 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 15/15] 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);