#include "app/app_facade_access.h" #include "app/linux_app_facade.h" #include "chat/linux_reticulum_network_store.h" #include "platform/desktop/sdl_lvgl_host.h" #include "platform/ui/lora_receive_session.h" #include "platform/ui/radio_iq_runtime.h" #include "sys/clock.h" #include "ui/screens/energy_sweep/energy_sweep_page_runtime.h" #include #include #include #include #include #include #include #include namespace { uint32_t clock_ms = 1000; uint32_t now() { return clock_ms; } platform::ui::lora::ReceiveSessionSnapshot receiver; unsigned configurations = 0, transmissions = 0, releases = 0; void require(bool value, const char* message) { if (!value) throw std::runtime_error(message); } void synchronousIo() { throw std::runtime_error("Protocol Probe performed synchronous radio I/O"); } void pump(uint32_t elapsed) { clock_ms += elapsed; lv_tick_inc(elapsed); lv_timer_handler(); } } // namespace namespace platform::ui::lora { bool is_supported() { return true; } CapabilityStatus capability_status() { return {CapabilityState::Available, "fake receiver"}; } bool acquire() { synchronousIo(); return false; } bool is_online() { synchronousIo(); return false; } bool configure_receive(float, const ReceiveConfig&) { synchronousIo(); return false; } bool transmit_packet(const uint8_t*, size_t) { synchronousIo(); return false; } bool poll_received_packet(uint8_t*, size_t, ReceivedPacket*) { synchronousIo(); return false; } float read_instant_rssi() { synchronousIo(); return 0; } void release() { synchronousIo(); } uint32_t request_receive_session(float, const ReceiveConfig&) { ++configurations; ++receiver.generation; receiver.state = ReceiveSessionState::Configuring; receiver.transmit_pending = false; return receiver.generation; } ReceiveSessionSnapshot receive_session_snapshot() { return receiver; } bool poll_receive_session(uint32_t generation, uint8_t*, size_t, ReceivedPacket*) { require(generation == receiver.generation && receiver.state == ReceiveSessionState::Ready, "page polled an unready/obsolete receiver"); return false; } bool transmit_receive_session(uint32_t generation, const uint8_t*, size_t size) { require(generation == receiver.generation && receiver.state == ReceiveSessionState::Ready && size, "page transmitted in wrong receiver context"); ++transmissions; receiver.transmit_pending = true; return true; } void release_receive_session(uint32_t generation) { require(generation == receiver.generation, "page released obsolete receiver"); ++releases; receiver.state = ReceiveSessionState::Idle; } bool wait_receive_session_released() { return receiver.state == ReceiveSessionState::Idle; } namespace detail { void prepare_receive_session_worker() {} } // namespace detail } // namespace platform::ui::lora #if defined(TRAIL_MATE_EXTERNAL_RADIO_IQ_RUNTIME) namespace platform::ui::radio_iq { Snapshot fixture; unsigned starts = 0, joins = 0; bool is_supported() { return true; } bool start() { ++starts; fixture = {}; fixture.phase = Phase::Capturing; return true; } Snapshot snapshot() { return fixture; } void wait_until_finished() { ++joins; fixture.phase = Phase::Idle; } } // namespace platform::ui::radio_iq lv_obj_t* label_containing(lv_obj_t* root, const char* text) { if (lv_obj_check_type(root, &lv_label_class) && std::strstr(lv_label_get_text(root), text)) return root; for (uint32_t i = 0; i < lv_obj_get_child_count(root); ++i) if (auto* found = label_containing(lv_obj_get_child(root, i), text)) return found; return nullptr; } void check_iq_ui(bool compact, platform::desktop::SdlLvglHost& host) { namespace iq = platform::ui::radio_iq; ui_energy_sweep_enter(lv_scr_act()); auto* label = label_containing(lv_scr_act(), "I IQ"); require(label, "IQ entry missing with a supplied runtime"); auto* button = lv_obj_get_parent(label); auto* page = lv_obj_get_parent(lv_obj_get_parent(button)); const auto capture_fixture = [&](const char* phase) { const char* directory = std::getenv("TRAIL_MATE_IQ_FIXTURE_SCREENSHOT_DIR"); if (!directory || !*directory) return; std::filesystem::create_directories(directory); // Export settled native widget transitions, not the disabled-to-ready // animation's first frame. Regular CI assertions do not sleep here. std::this_thread::sleep_for(std::chrono::milliseconds(200)); pump(200); auto* heading = label_containing(page, "IQ DIAGNOSTIC"); const std::string saved = heading ? lv_label_get_text(heading) : ""; if (heading) lv_label_set_text(heading, "TEST FIXTURE - NO RF"); lv_refr_now(nullptr); host.saveScreenshot((std::filesystem::path(directory) / (std::string(compact ? "compact-iq-" : "wide-iq-") + phase + ".bmp")).string()); if (heading) lv_label_set_text(heading, saved.c_str()); }; require(lv_obj_get_width(page) == (compact ? 320 : 1232) && lv_obj_get_height(page) == (compact ? 170 : 568), "IQ page did not use the requested display dimensions"); lv_obj_update_layout(page); lv_area_t caption, control; lv_obj_get_coords(label, &caption); lv_obj_get_coords(button, &control); require(caption.x1 >= control.x1 && caption.x2 <= control.x2, "IQ entry caption clips outside button"); lv_obj_send_event(button, LV_EVENT_CLICKED, nullptr); require(iq::starts == 1, "IQ button did not reach runtime"); require(lv_obj_has_state(button, LV_STATE_DISABLED), "capturing IQ can be started twice"); const auto configured = configurations; uint32_t key = 'S'; lv_obj_send_event(page, LV_EVENT_KEY, &key); require(configurations == configured, "probe started while IQ owns radio"); pump(35); require(label_containing(page, "Capturing IQ"), "missing capture progress"); iq::fixture.phase = iq::Phase::Ready; iq::fixture.pairs = 256; iq::fixture.rms = 0.5f; pump(35); auto* result = label_containing(page, "256 IQ pairs"); require(!lv_obj_has_state(button, LV_STATE_DISABLED), "IQ entry stayed disabled after recovery"); require(result && std::strstr(lv_label_get_text(result), "rate: unqualified"), "unknown IQ clock fabricated"); require(std::strstr(lv_label_get_text(result), "no protocol classification"), "IQ presented as protocol evidence"); lv_obj_update_layout(page); auto* viewport = lv_obj_get_parent(result); lv_area_t bounds, content; lv_obj_get_coords(viewport, &bounds); lv_obj_get_coords(lv_obj_get_parent(viewport), &content); require(bounds.x1 >= content.x1 && bounds.x2 <= content.x2 && bounds.y1 >= content.y1 && bounds.y2 <= content.y2, "IQ result clips outside content"); auto* progress = label_containing(page, "S: RETURN TO PROBE"); require(progress, "missing IQ return hint"); lv_area_t footer; lv_obj_get_coords(progress, &footer); require(bounds.y2 < footer.y1 && lv_obj_get_width(result) <= lv_obj_get_width(viewport), "IQ overlaps footer or clips horizontally"); capture_fixture("ready"); for (unsigned n = 0; n < 20 && lv_obj_get_scroll_bottom(viewport) > 0; ++n) { key = LV_KEY_DOWN; lv_obj_send_event(page, LV_EVENT_KEY, &key); lv_obj_update_layout(page); } require(lv_obj_get_scroll_bottom(viewport) <= 0, "IQ diagnostic tail cannot be reached with Down"); iq::fixture.phase = iq::Phase::Error; std::snprintf(iq::fixture.error, sizeof(iq::fixture.error), "Fixture error: simulated failure while restoring the radio after IQ capture. No RF hardware used."); pump(35); require(label_containing(page, "No valid IQ published"), "failure retains success report"); capture_fixture("error"); lv_obj_update_layout(page); for (unsigned n = 0; n < 32 && lv_obj_get_scroll_bottom(viewport) > 0; ++n) { key = LV_KEY_DOWN; lv_obj_send_event(page, LV_EVENT_KEY, &key); lv_obj_update_layout(page); } require(lv_obj_get_scroll_bottom(viewport) <= 0, "long IQ failure cannot be reached with Down"); key = 'I'; lv_obj_send_event(page, LV_EVENT_KEY, &key); require(iq::starts == 2, "IQ keyboard shortcut missing"); lv_obj_update_layout(page); require(lv_obj_get_scroll_y(viewport) == 0, "new IQ capture retained the previous scroll offset"); ui_energy_sweep_exit(lv_scr_act()); require(iq::joins == 1, "page exit did not join radio restoration"); } #endif int main(int argc, char** argv) { namespace runtime = platform::linux_runtime; namespace probe = energy_sweep::ui::runtime; const auto root = std::filesystem::temp_directory_path() / ("trailmate-probe-session-" + std::to_string(getpid())); std::filesystem::create_directories(root); for (const char* key : {"TRAIL_MATE_SETTINGS_ROOT", "TRAIL_MATE_SD_ROOT", "TRAIL_MATE_CACHE_ROOT"}) setenv(key, root.c_str(), 1); setenv("SDL_VIDEODRIVER", "dummy", 1); setenv("SDL_AUDIODRIVER", "dummy", 1); runtime::PacketRadioHardware hardware; hardware.driver = "none"; require(runtime::configure_packet_radio(hardware), "isolated radio setup failed"); require(runtime::LinuxReticulumNetworkStore::save(chat::reticulum::ReticulumNetworkConfig{}, app::AppConfig{}), "isolated config setup failed"); lv_init(); { platform::desktop::DeviceOptions options; options.ux_pack = "t_display_p4_touch"; const bool compact = argc > 1 && std::strcmp(argv[1], "compact") == 0; options.width = compact ? 320 : 1232; options.height = compact ? 170 : 568; options.fullscreen = false; platform::desktop::SdlLvglHost host(options); trailmate::cardputer_zero::linux_ui::MinimalLinuxAppFacade facade({.runtime_mode = runtime::LinuxRuntimeMode::DeviceLocal}); require(facade.initialize(), "facade initialization failed"); require(app::configFacade().switchMeshProtocol(chat::MeshProtocol::MeshCore, false), "MeshCore selection failed"); sys::set_millis_provider(now); require(probe::text_start(), "probe did not accept asynchronous startup"); require(configurations == 1, "initial candidate missing"); pump(60000); require(configurations == 1 && !transmissions, "configuration delay consumed dwell/started TX"); receiver.state = platform::ui::lora::ReceiveSessionState::Ready; pump(60); pump(1200); require(transmissions == 1 && receiver.transmit_pending, "active verification not queued"); pump(60000); require(configurations == 1, "response window expired before TX completed"); receiver.transmit_pending = false; pump(60); pump(4500); require(configurations == 1, "response window not restarted after TX completion"); pump(120000); require(configurations == 2, "completed candidate did not advance"); receiver.state = platform::ui::lora::ReceiveSessionState::Failed; pump(60); probe::TextSnapshot snapshot; require(probe::text_snapshot(snapshot) && snapshot.radio_error && !snapshot.scanning, "worker failure did not stop probe"); require(releases == 1, "failed probe did not release its generation"); probe::text_stop(); #if defined(TRAIL_MATE_EXTERNAL_RADIO_IQ_RUNTIME) check_iq_ui(compact, host); #endif sys::set_millis_provider(nullptr); facade.shutdown(); } lv_deinit(); std::filesystem::remove_all(root); }