diff --git a/firmware/application/baseband_api.cpp b/firmware/application/baseband_api.cpp index 54d7b2b28..cea9a51e0 100644 --- a/firmware/application/baseband_api.cpp +++ b/firmware/application/baseband_api.cpp @@ -455,6 +455,14 @@ void set_p25tx_data(const uint8_t* dibits, uint16_t frame_length) { send_message(&msg); } +void set_hunter_config(uint32_t threshold, uint32_t hangtime_ms, bool start) { + HunterConfigMessage message{}; + message.energy_threshold = threshold; + message.hangtime_ms = hangtime_ms; + message.start = start; + send_message(&message); +} + static bool baseband_image_running = false; bool is_image_running() { diff --git a/firmware/application/baseband_api.hpp b/firmware/application/baseband_api.hpp index fbf3b2258..10eee20b5 100644 --- a/firmware/application/baseband_api.hpp +++ b/firmware/application/baseband_api.hpp @@ -122,6 +122,7 @@ void set_rtty_config(RTTYDataMessage& message); void set_epirb_tx_config(EPIRBTXDataMessage& message); void set_epirb_rx_config(EPIRBRXConfig& message); void set_p25tx_data(const uint8_t* dibits, uint16_t frame_length); +void set_hunter_config(uint32_t threshold, uint32_t hangtime_ms, bool start); void request_roger_beep(); void request_rssi_beep(); diff --git a/firmware/application/external/external.cmake b/firmware/application/external/external.cmake index 1bd66eb39..7a8d07d55 100644 --- a/firmware/application/external/external.cmake +++ b/firmware/application/external/external.cmake @@ -375,6 +375,10 @@ set(EXTCPPSRC external/secplustx/main.cpp external/secplustx/ui_secplustx.cpp external/secplustx/secplustx.cpp + + #signal_hunter + external/signal_hunter/main.cpp + external/signal_hunter/ui_signal_hunter.cpp ) set(EXTAPPLIST @@ -467,6 +471,7 @@ set(EXTAPPLIST two_tone_rx hard_reset secplustx + signal_hunter ) # sdusb has type conflicts with PRALINE (HackRF Pro) - add only for non-PRALINE builds diff --git a/firmware/application/external/external.ld b/firmware/application/external/external.ld index 9a4e8b803..bb06da7ec 100644 --- a/firmware/application/external/external.ld +++ b/firmware/application/external/external.ld @@ -113,6 +113,7 @@ MEMORY ram_external_app_secplustx (rwx) : org = 0xAE080000, len = 32k ram_external_app_vor_rx (rwx) : org = 0xAE090000, len = 32k ram_external_app_vor_tx (rwx) : org = 0xAE0A0000, len = 32k + ram_external_app_signal_hunter (rwx) : org = 0xAE0B0000, len = 32k } SECTIONS @@ -656,4 +657,10 @@ SECTIONS KEEP(*(.external_app.app_secplustx.application_information)); *(*ui*external_app*secplustx*); } > ram_external_app_secplustx + + .external_app_signal_hunter : ALIGN(4) SUBALIGN(4) + { + KEEP(*(.external_app.app_signal_hunter.application_information)); + *(*ui*external_app*signal_hunter*); + } > ram_external_app_signal_hunter } diff --git a/firmware/application/external/signal_hunter/main.cpp b/firmware/application/external/signal_hunter/main.cpp new file mode 100644 index 000000000..06abade57 --- /dev/null +++ b/firmware/application/external/signal_hunter/main.cpp @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2026 Matej Sochan + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#include "ui.hpp" +#include "ui_signal_hunter.hpp" +#include "ui_navigation.hpp" +#include "external_app.hpp" + +namespace ui::external_app::signal_hunter { +void initialize_app(ui::NavigationView& nav) { + nav.push(); +} +} // namespace ui::external_app::signal_hunter + +extern "C" { +__attribute__((section(".external_app.app_signal_hunter.application_information"), used)) application_information_t _application_information_signal_hunter = { + /*.memory_location = */ (uint8_t*)0x00000000, + /*.externalAppEntry = */ ui::external_app::signal_hunter::initialize_app, + /*.header_version = */ CURRENT_HEADER_VERSION, + /*.app_version = */ VERSION_MD5, + /*.app_name = */ "SigHunter", + /*.bitmap_data = */ {0x80, 0x01, 0x80, 0x01, 0xE0, 0x07, 0xF0, 0x0F, 0x98, 0x19, 0x8C, 0x31, 0x84, 0x21, 0xFF, 0xFF, 0xFF, 0xFF, 0x84, 0x21, 0x8C, 0x31, 0x98, 0x19, 0xF0, 0x0F, 0xE0, 0x07, 0x80, 0x01, 0x80, 0x01}, + /*.icon_color = */ ui::Color::green().v, + /*.menu_location = */ app_location_t::RX, + /*.desired_menu_position = */ -1, + + /*.m4_app_tag = */ {'H', 'U', 'N', 'T'}, + /*.m4_app_offset = */ 0x00000000, +}; +} diff --git a/firmware/application/external/signal_hunter/ui_signal_hunter.cpp b/firmware/application/external/signal_hunter/ui_signal_hunter.cpp new file mode 100644 index 000000000..a81d34aca --- /dev/null +++ b/firmware/application/external/signal_hunter/ui_signal_hunter.cpp @@ -0,0 +1,403 @@ +/* + * Copyright (C) 2026 Matej Sochan + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#include "ui_signal_hunter.hpp" +#include "receiver_model.hpp" +#include "baseband_api.hpp" +#include "ui_fileman.hpp" +#include "freqman_db.hpp" +#include "string_format.hpp" +#include "io_file.hpp" +#include "rtc_time.hpp" + +using namespace portapack; + +namespace ui::external_app::signal_hunter { + +// --- TAB 1: Main View --- +HunterMainView::HunterMainView(Rect parent_rect, SignalHunterAppView& parent) + : View(parent_rect), parent_app(parent) { + add_children({&text_current_freq, &text_status, &button_start_stop, &text_hits}); + update_frequency(433920000); + + button_start_stop.on_select = [this](Button&) { + parent_app.is_hunting = !parent_app.is_hunting; + if (parent_app.is_hunting) { + button_start_stop.set_text("STOP"); + // Set initial frequency based on set mode (single freq / freq hopping) + if (parent_app.freq_hop_mode && !parent_app.frequency_list.empty()) { + parent_app.current_freq_index = 0; + auto freq = parent_app.frequency_list[0]; + receiver_model.set_target_frequency(freq); + update_frequency(freq); + } else { + receiver_model.set_target_frequency(parent_app.single_frequency); + update_frequency(parent_app.single_frequency); + } + update_status("HUNTING...", Theme::getInstance()->fg_green); + parent_app.send_hunter_config(true); + } else { + button_start_stop.set_text("START"); + update_status("IDLE", Theme::getInstance()->fg_light); + set_recording_state(false); + parent_app.send_hunter_config(false); + } + }; +} + +void HunterMainView::on_show() { + if (!parent_app.freq_hop_mode) { + current_freq_ = receiver_model.target_frequency(); + } + + update_frequency(current_freq_); // force repaint +} + +void HunterMainView::focus() { + if (!parent_app.freq_hop_mode) { + current_freq_ = receiver_model.target_frequency(); + } + + update_frequency(current_freq_); + button_start_stop.focus(); +} + +void HunterMainView::set_recording_state(bool recording) { + if (recording) + text_current_freq.set_style(Theme::getInstance()->fg_red); + else + text_current_freq.set_style(Theme::getInstance()->fg_light); + + text_current_freq.set_dirty(); // force ui update + update_frequency(current_freq_); // repaint with the new color +} + +void HunterMainView::update_frequency(rf::Frequency freq) { + current_freq_ = freq; + text_current_freq.set(to_string_dec_uint(freq) + " Hz"); +} + +void HunterMainView::update_status(const std::string& status, const Style* style) { + text_status.set(status); + text_status.set_style(style); +} + +void HunterMainView::update_hits(uint32_t hits) { + text_hits.set("Hits: " + to_string_dec_uint(hits)); +} + +// --- TAB 2: Freqs View --- +HunterFreqsView::HunterFreqsView(Rect parent_rect, SignalHunterAppView& parent) + : View(parent_rect), parent_app(parent) { + add_children({&button_load_file, + &button_clear, + &labels, + &field_dwell, + &text_loaded_info}); + + field_dwell.set_value(parent_app.hop_dwell_ms); + field_dwell.on_change = [this](int32_t v) { parent_app.hop_dwell_ms = v; }; + + button_load_file.on_select = [this](Button&) { + auto* view = parent_app.get_nav().push(".TXT"); + if (view) { + view->on_changed = [this](std::filesystem::path path) { + parent_app.frequency_list.clear(); + parent_app.current_freq_index = 0; + FreqmanDB db{}; + db.open(path); + for (const auto& entry : db) { + if (entry.frequency_a > 0) + parent_app.frequency_list.push_back(entry.frequency_a); + } + parent_app.freqman_file = path.stem().string(); + + // Switch to HOP mode if a file was loaded + if (!parent_app.frequency_list.empty()) + parent_app.freq_hop_mode = true; + + update_list_count(); + + // Update the mode display in the Config tab + if (parent_app.get_config_view()) { + parent_app.get_config_view()->update_mode_display(); + } + }; + } + }; + + button_clear.on_select = [this](Button&) { + parent_app.frequency_list.clear(); + parent_app.current_freq_index = 0; + parent_app.freq_hop_mode = false; + + update_list_count(); + + // Update the mode display in the Config tab + if (parent_app.get_config_view()) { + parent_app.get_config_view()->update_mode_display(); + } + }; +} + +void HunterFreqsView::update_list_count() { + text_loaded_info.set("Loaded: " + + to_string_dec_uint(parent_app.frequency_list.size()) + " freqs"); +} + +void HunterFreqsView::focus() { + button_load_file.focus(); +} + +// --- TAB 3: Config View --- +HunterConfigView::HunterConfigView(Rect parent_rect, SignalHunterAppView& parent) + : View(parent_rect), parent_app(parent), field_single_freq{{UI_POS_X(2), UI_POS_Y(4)}, parent.get_nav()} { + add_children({&button_mode, + &field_single_freq, + &labels, + &field_threshold, + &field_hang_time, + &text_info_config}); + + button_mode.on_select = [this](Button&) { + parent_app.freq_hop_mode = !parent_app.freq_hop_mode; + update_mode_display(); + }; + + field_single_freq.set_value(parent_app.single_frequency); + + field_threshold.set_value(parent_app.energy_threshold); + field_threshold.on_change = [this](int32_t v) { parent_app.energy_threshold = v; }; + + field_hang_time.set_value(parent_app.hangtime_ms); + field_hang_time.on_change = [this](int32_t v) { parent_app.hangtime_ms = v; }; + + update_mode_display(); +} + +void HunterConfigView::on_show() { + auto current = receiver_model.target_frequency(); + parent_app.single_frequency = current; + field_single_freq.set_value(current); +} + +void HunterConfigView::update_mode_display() { + if (parent_app.freq_hop_mode) + button_mode.set_text("MODE: HOP (" + + to_string_dec_uint(parent_app.frequency_list.size()) + " freqs)"); + else + button_mode.set_text("MODE: SINGLE"); +} + +void HunterConfigView::focus() { + field_threshold.focus(); +} + +// --- Main App View --- +SignalHunterAppView::SignalHunterAppView(ui::NavigationView& nav) + : frequency_list{}, + nav_(nav) { + baseband::run_prepared_image(portapack::memory::map::m4_code.base()); + + receiver_model.set_target_frequency(433920000); + receiver_model.set_baseband_bandwidth(1750000); + receiver_model.set_sampling_rate(2000000); + receiver_model.enable(); + + view_main = std::make_unique(content_rect, *this); + view_freqs = std::make_unique(content_rect, *this); + view_config = std::make_unique(content_rect, *this); + + tab_view = std::make_unique(std::initializer_list{ + {"Target", Color::cyan(), view_main.get()}, + {"Freqs", Color::green(), view_freqs.get()}, + {"Config", Color::yellow(), view_config.get()}}); + tab_view->set_parent_rect(view_rect); + + add_children({&field_lna, + &field_vga, + &field_rf_amp, + &rssi, + tab_view.get(), + view_main.get(), + view_freqs.get(), + view_config.get()}); + + view_freqs->hidden(true); + view_config->hidden(true); + + using namespace app_settings; + settings_ = std::make_unique( + "ext_signal_hunter", Mode::RX, + SettingBindings{ + {"threshold"sv, &energy_threshold}, + {"hangtime_ms"sv, &hangtime_ms}, + {"hop_dwell_ms"sv, &hop_dwell_ms}, + {"single_freq"sv, &single_frequency}, + {"file"sv, &freqman_file}}); +} + +SignalHunterAppView::~SignalHunterAppView() { + if (!freq_hop_mode) { + single_frequency = receiver_model.target_frequency(); + } + + // Safely shutdown recording + stop_recording(); + receiver_model.disable(); + baseband::shutdown(); +} + +void SignalHunterAppView::focus() { + if (tab_view) + tab_view->focus(); +} + +// TIMER LOGIC: Based on display refresh rate +void SignalHunterAppView::on_frame_sync() { + // Timer running only if, HUNTING, in HOPPING mode, not recording + we have the freqs list + if (is_hunting && freq_hop_mode && !capture_thread && !frequency_list.empty()) { + hop_timer_ms += 17; // 1 frame in a 60 Hz display ~ 16.6 ms + + // if Dwell Time has passed, change frequency + if (hop_timer_ms >= hop_dwell_ms) { + hop_timer_ms = 0; + + current_freq_index = (current_freq_index + 1) % frequency_list.size(); + auto next_freq = frequency_list[current_freq_index]; + receiver_model.set_target_frequency(next_freq); + + if (view_main) { + view_main->update_frequency(next_freq); + } + } + } else { + hop_timer_ms = 0; // if not hunting / recording -> timer = 0 + } +} + +// Recording control logic via M0 <-> M4 IPC +void SignalHunterAppView::send_hunter_config(bool start) { + // Send config to baseband processor via IPC mechanism + baseband::set_hunter_config(energy_threshold, hangtime_ms, start); + + if (!start) { + stop_recording(); + } +} + +void SignalHunterAppView::on_hunter_trigger(const HunterTriggerMessage* /*message*/) { + // M4 has detected signal above threshold - start I/Q capture + if (!capture_thread && is_hunting) { + trigger_hits++; + view_main->update_hits(trigger_hits); + view_main->update_status("RECORDING!", Theme::getInstance()->fg_red); + view_main->set_recording_state(true); + + start_recording(); + } +} + +void SignalHunterAppView::on_hunter_stop(const HunterStopMessage*) { + if (capture_thread) { + stop_recording(); + view_main->set_recording_state(false); + + if (is_hunting) { + if (freq_hop_mode && !frequency_list.empty()) { + // Next frequency + current_freq_index = (current_freq_index + 1) % frequency_list.size(); + auto next_freq = frequency_list[current_freq_index]; + receiver_model.set_target_frequency(next_freq); + view_main->update_frequency(next_freq); + + // Reset the timer + hop_timer_ms = 0; + } + view_main->update_status("HUNTING...", Theme::getInstance()->fg_green); + } + } +} + +void SignalHunterAppView::start_recording() { + rtc::RTC datetime{}; + rtc_time::now(datetime); + + // Generate timestamped filename for capture session + const std::string capture_filename = "HNT_" + + to_string_dec_uint(datetime.year(), 4, '0') + + to_string_dec_uint(datetime.month(), 2, '0') + + to_string_dec_uint(datetime.day(), 2, '0') + "T" + + to_string_dec_uint(datetime.hour(), 2, '0') + + to_string_dec_uint(datetime.minute(), 2, '0') + + to_string_dec_uint(datetime.second(), 2, '0'); + + // Use FileWriter for direct raw I/Q dump to avoid M0 CPU bottlenecks. + // FileConvertWriter is heavier + auto writer = std::make_unique(); + auto create_error = writer->create(std::filesystem::path(u"/CAPTURES") / (capture_filename + ".C16")); + + if (create_error.is_valid()) { + if (view_main) view_main->update_status("SD ERROR", Theme::getInstance()->fg_red); + + send_hunter_config(false); + is_hunting = false; + if (view_main) view_main->set_start_button_text("START"); + + return; + } + + current_capture_filename = capture_filename; + + capture_thread = std::make_unique( + std::move(writer), + 4096, + 4, + []() {}, + [](File::Error) {}); + + if (view_main) view_main->update_status("RECORDING!", Theme::getInstance()->fg_red); +} + +void SignalHunterAppView::stop_recording() { + // Stop I/Q capture and release SD card for metadata write + capture_thread.reset(); + + // Generate metadata file after capture completes + if (!current_capture_filename.empty()) { + File txt_file; + auto txt_error = txt_file.create(std::filesystem::path(u"/CAPTURES") / (current_capture_filename + ".TXT")); + + // is_valid() returns true only if error is present (counter-intuitive) + if (!txt_error.is_valid()) { + std::string metadata = + "center_frequency=" + to_string_dec_uint(receiver_model.target_frequency()) + "\n" + + // Divide main sample rate by decimation factor (8) to match C16 sample rate + "sample_rate=" + to_string_dec_uint(receiver_model.sampling_rate() / 8) + "\n"; + + txt_file.write(metadata.c_str(), metadata.length()); + } + // Clear filename for next capture cycle + current_capture_filename.clear(); + } +} + +} // namespace ui::external_app::signal_hunter diff --git a/firmware/application/external/signal_hunter/ui_signal_hunter.hpp b/firmware/application/external/signal_hunter/ui_signal_hunter.hpp new file mode 100644 index 000000000..294e9bf92 --- /dev/null +++ b/firmware/application/external/signal_hunter/ui_signal_hunter.hpp @@ -0,0 +1,192 @@ +/* + * Copyright (C) 2026 Matej Sochan + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#ifndef __UI_SIGNAL_HUNTER_H__ +#define __UI_SIGNAL_HUNTER_H__ + +#include "app_settings.hpp" +#include "rf_path.hpp" +#include "ui_widget.hpp" +#include "ui_navigation.hpp" +#include "ui_receiver.hpp" +#include "ui_tabview.hpp" +#include "ui_freq_field.hpp" +#include "capture_thread.hpp" +#include "message.hpp" +#include +#include + +namespace ui::external_app::signal_hunter { + +class SignalHunterAppView; + +// --- TAB 1: Main View --- +class HunterMainView : public View { + public: + HunterMainView(Rect parent_rect, SignalHunterAppView& parent); + void on_show() override; + void focus() override; + void update_status(const std::string& status, const Style* style); + void update_hits(uint32_t hits); + void update_frequency(rf::Frequency freq); + void set_recording_state(bool recording); + void set_start_button_text(const std::string& text) { button_start_stop.set_text(text); } + + private: + SignalHunterAppView& parent_app; + rf::Frequency current_freq_{433920000}; + Text text_current_freq{{UI_POS_X_CENTER(14), UI_POS_Y(3), 112, 16}, ""}; + Text text_status{{UI_POS_X_CENTER(12), UI_POS_Y(6), 96, 16}, "IDLE"}; + Button button_start_stop{{UI_POS_X_CENTER(10), UI_POS_Y(8), 80, 32}, "START"}; + Text text_hits{{UI_POS_X_CENTER(10), UI_POS_Y(11), 80, 16}, "Hits: 0"}; +}; + +// --- TAB 2: Freqs View --- +class HunterFreqsView : public View { + public: + HunterFreqsView(Rect parent_rect, SignalHunterAppView& parent); + void focus() override; + void update_list_count(); + + private: + SignalHunterAppView& parent_app; + Button button_load_file{{UI_POS_X(2), UI_POS_Y(1), 80, 28}, "LOAD FILE"}; + Button button_clear{{UI_POS_X_RIGHT(12), UI_POS_Y(1), 80, 28}, "CLEAR"}; + + Labels labels{ + {{UI_POS_X(2), UI_POS_Y(4)}, "Dwell Time (ms):", Color::light_grey()}}; + NumberField field_dwell{{UI_POS_X_RIGHT(8), UI_POS_Y(4)}, 4, {10, 9999}, 10, ' '}; + + Text text_loaded_info{{UI_POS_X(2), UI_POS_Y(6), 208, 16}, "Loaded: 0 freqs"}; +}; + +// --- TAB 3: Config View --- +class HunterConfigView : public View { + public: + HunterConfigView(Rect parent_rect, SignalHunterAppView& parent); + void update_mode_display(); + void focus() override; + void on_show() override; + + private: + SignalHunterAppView& parent_app; + + Button button_mode{ + {UI_POS_X(2), UI_POS_Y(2), 208, 28}, + "MODE: SINGLE"}; + + RxFrequencyField field_single_freq; // UI_POS_Y(4) + + Labels labels{ + {{UI_POS_X(2), UI_POS_Y(6)}, "Energy Threshold:", Color::light_grey()}, + {{UI_POS_X(2), UI_POS_Y(8)}, "Hang-Time (ms):", Color::light_grey()}}; + + NumberField field_threshold{{UI_POS_X_RIGHT(8), UI_POS_Y(6)}, 5, {100, 99999}, 100, ' '}; + NumberField field_hang_time{{UI_POS_X_RIGHT(8), UI_POS_Y(8)}, 4, {10, 5000}, 10, ' '}; + + Text text_info_config{ + {UI_POS_X(2), UI_POS_Y(10), 208, 16}, + "Restart HUNT after change"}; +}; + +// --- MAIN APP VIEW --- +class SignalHunterAppView final : public ui::View { + public: + SignalHunterAppView(ui::NavigationView& nav); + ~SignalHunterAppView(); + void focus() override; + std::string title() const override { return "SignalHunter"; } + ui::NavigationView& get_nav() { return nav_; } + + // Application state shared across all views + std::vector frequency_list; + uint32_t current_freq_index{0}; + uint32_t energy_threshold{5000}; + uint32_t hangtime_ms{500}; + uint32_t hop_dwell_ms{200}; + std::string freqman_file{"TARGETS"}; + bool is_hunting{false}; + uint32_t trigger_hits{0}; + rf::Frequency single_frequency{433920000}; + bool freq_hop_mode{false}; + + void send_hunter_config(bool start); + HunterMainView* get_main_view() { return view_main.get(); } + HunterConfigView* get_config_view() { return view_config.get(); } + + private: + ui::NavigationView& nav_; + + std::unique_ptr settings_{}; + std::unique_ptr view_main{}; + std::unique_ptr view_freqs{}; + std::unique_ptr view_config{}; + std::unique_ptr tab_view{}; + + std::string current_capture_filename{}; + std::unique_ptr capture_thread{}; + + ui::LNAGainField field_lna{{UI_POS_X(0), UI_POS_Y(0)}}; + ui::VGAGainField field_vga{{UI_POS_X(7), UI_POS_Y(0)}}; + ui::RFAmpField field_rf_amp{{UI_POS_X(14), UI_POS_Y(0)}}; + ui::RSSI rssi{{UI_POS_X(0), UI_POS_Y(1), UI_POS_MAXWIDTH, 4}}; + + static constexpr Dim tab_bar_h = 20; + + Rect view_rect{ + 0, UI_POS_Y(2) + 4, UI_POS_MAXWIDTH, + screen_height - (UI_POS_Y(2) + 4) - UI_POS_HEIGHT(1)}; + + Rect content_rect{ + 0, UI_POS_Y(2) + 4 + tab_bar_h, UI_POS_MAXWIDTH, + screen_height - (UI_POS_Y(2) + 4 + tab_bar_h) - UI_POS_HEIGHT(1)}; + + void on_hunter_trigger(const HunterTriggerMessage* message); + void on_hunter_stop(const HunterStopMessage* message); + void start_recording(); + void stop_recording(); + + // timer variables + uint32_t hop_timer_ms{0}; + void on_frame_sync(); + + MessageHandlerRegistration message_handler_frame_sync{ + Message::ID::DisplayFrameSync, + [this](const Message* const) { + this->on_frame_sync(); + }}; + + MessageHandlerRegistration message_handler_trigger{ + Message::ID::HunterTrigger, + [this](const Message* const p) { + this->on_hunter_trigger(static_cast(p)); + }}; + + MessageHandlerRegistration message_handler_stop{ + Message::ID::HunterStop, + [this](const Message* const p) { + this->on_hunter_stop(static_cast(p)); + }}; +}; + +} // namespace ui::external_app::signal_hunter + +#endif /*__UI_SIGNAL_HUNTER_H__*/ diff --git a/firmware/baseband/CMakeLists.txt b/firmware/baseband/CMakeLists.txt index e90364e20..6fbdb1b25 100644 --- a/firmware/baseband/CMakeLists.txt +++ b/firmware/baseband/CMakeLists.txt @@ -769,6 +769,13 @@ set(MODE_CPPSRC ) DeclareTargets(PTON tones) +### Signal Hunter + +set(MODE_CPPSRC + proc_signal_hunter.cpp +) +DeclareTargets(HUNT signal_hunter) + ### Time Sink diff --git a/firmware/baseband/proc_signal_hunter.cpp b/firmware/baseband/proc_signal_hunter.cpp new file mode 100644 index 000000000..e908210cb --- /dev/null +++ b/firmware/baseband/proc_signal_hunter.cpp @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2026 Matej Sochan + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#include "proc_signal_hunter.hpp" +#include "event_m4.hpp" +#include "portapack_shared_memory.hpp" +#include "dsp_fir_taps.hpp" + +void SignalHunterProcessor::configure() { + decim_0.configure(taps_200k_decim_0.taps); + + window_idx = 0; + window_sum = 0; + for (auto& v : window_buf) v = 0; + + reset_hunt_state(); + configured = true; +} + +void SignalHunterProcessor::reset_hunt_state() { + iq_ring_idx = 0; + for (auto& v : iq_ring) v = complex16_t{0, 0}; + hangtime_counter = 0; + hunt_state = HuntState::IDLE; + flush_pending = false; +} + +void SignalHunterProcessor::execute(const buffer_c8_t& buffer) { + // Process stream closure. + // Deferred teardown ensures exclusive mutation of the hunt state by the BasebandThread, + // preventing data races with on_message(). + if (stream_close_requested.exchange(false)) { + stream.reset(); + reset_hunt_state(); + } + + // CRITICAL IPC FIX: If M0 is tearing down the capture thread, it needs one last buffer + // to unblock buffers.get() and exit gracefully. We MUST continue decimating and writing + // as long as the stream exists, even if hunting was set to false by a manual UI stop. + if (!hunting && !stream) return; + + const auto out = decim_0.execute(buffer, dst_buffer); + feed_channel_stats(out); + + // Pre-roll flush: first execute() call after CaptureConfigMessage creates stream. + if (flush_pending && stream_active && stream) { + // Write ring buffer from oldest to newest sample (2 chunks due to wrap-around) + size_t first = IQ_RING_SAMPLES - flush_start_idx; + stream->write(&iq_ring[flush_start_idx], first * sizeof(complex16_t)); + if (flush_start_idx > 0) + stream->write(&iq_ring[0], flush_start_idx * sizeof(complex16_t)); + flush_pending = false; + } + + for (size_t i = 0; i < out.count; i++) { + auto s = out.p[i]; + + iq_ring[iq_ring_idx] = s; + iq_ring_idx = (iq_ring_idx + 1) % IQ_RING_SAMPLES; + + uint32_t energy = ((int32_t)s.real() * s.real() + (int32_t)s.imag() * s.imag()) >> 16; + window_sum -= window_buf[window_idx]; + window_buf[window_idx] = energy; + window_sum += energy; + window_idx = (window_idx + 1) % WINDOW_SIZE; + + uint32_t avg = window_sum / WINDOW_SIZE; + + switch (hunt_state.load()) { + case HuntState::IDLE: + // Only trigger new recordings if we are actively hunting. + // Prevents a stray trigger from starting a new capture right after manual STOP. + if (hunting && (avg > energy_threshold)) { + HunterTriggerMessage msg{}; + msg.energy = avg; + shared_memory.application_queue.push(msg); + hunt_state = HuntState::AWAITING_STREAM; + } + break; + + case HuntState::AWAITING_STREAM: + break; + + case HuntState::RECORDING: + if (avg < energy_threshold) { + hangtime_counter = hangtime_samples_limit; + hunt_state = HuntState::HANGTIME; + } + break; + + case HuntState::HANGTIME: + if (avg > energy_threshold) { + hunt_state = HuntState::RECORDING; + } else if (--hangtime_counter == 0) { + HunterStopMessage stop_msg{}; + shared_memory.application_queue.push(stop_msg); + hunt_state = HuntState::AWAITING_CLOSE; + } + break; + + case HuntState::AWAITING_CLOSE: + // Do not transition to IDLE by ourselves — wait for CaptureConfigMessage(nullptr) + // which arrives via BasebandCapture destructor after CaptureThread is destroyed. + break; + } + } + + // CRITICAL: Continue writing to stream whenever it exists, even in AWAITING_CLOSE state, + // and even after a manual STOP — M0's CaptureThread needs this final data to unblock. + if (stream_active && stream) { + stream->write(out.p, sizeof(complex16_t) * out.count); + } +} + +void SignalHunterProcessor::on_message(const Message* const message) { + switch (message->id) { + case Message::ID::HunterConfig: { + const auto& m = *reinterpret_cast(message); + energy_threshold = m.energy_threshold; + + // Convert hangtime to post-decimation samples: + // 1 ms = 250 samples @ 250 kHz post-decimation rate (from 2 MHz baseband / 8x decimator) + // This dynamic hangtime allows configurable silence tolerance (e.g., 500 ms) + hangtime_samples_limit = m.hangtime_ms * 250; + + // Addresses "WHAT-IF" integer underflow + if (hangtime_samples_limit == 0) { + hangtime_samples_limit = 1; + } + + if (!configured) configure(); + + // DO NOT close the stream here if (!m.start). + // M4 must keep producing buffers until M0 explicitly sends CaptureConfig(nullptr) + // to prevent the M0 CaptureThread from deadlocking in buffers.get(). + hunting = m.start; + break; + } + + case Message::ID::CaptureConfig: { + const auto& m = *reinterpret_cast(message); + + if (m.config) { + // Synchronous allocation guarantees validity of m.config pointer + // (must not be deferred into execute(), see prior HardFault). + stream = std::make_unique(m.config); + flush_start_idx = iq_ring_idx; // Snapshot: current write position + flush_pending = true; // execute() will process ring buffer pre-roll + stream_active = true; + hunt_state = HuntState::RECORDING; + } else { + // This is the ONLY safe place to initiate stream teardown — + // it arrives after M0's CaptureThread has already fully drained + // and exited, so it's safe for execute() to reset() the stream. + stream_active = false; + stream_close_requested = true; + } + + break; + } + + default: + break; + } +} + +int main() { + EventDispatcher event_dispatcher{std::make_unique()}; + event_dispatcher.run(); + return 0; +} diff --git a/firmware/baseband/proc_signal_hunter.hpp b/firmware/baseband/proc_signal_hunter.hpp new file mode 100644 index 000000000..626587cec --- /dev/null +++ b/firmware/baseband/proc_signal_hunter.hpp @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2026 Matej Sochan + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#ifndef __PROC_SIGNAL_HUNTER_H__ +#define __PROC_SIGNAL_HUNTER_H__ + +#include + +#include "baseband_processor.hpp" +#include "baseband_thread.hpp" +#include "rssi_thread.hpp" +#include "message.hpp" +#include "dsp_decimate.hpp" +#include "stream_input.hpp" + +#include +#include +#include +#include + +class SignalHunterProcessor : public BasebandProcessor { + public: + void execute(const buffer_c8_t& buffer) override; + void on_message(const Message* const message) override; + + private: + static constexpr size_t baseband_fs = 2000000; + + // Hardcoded Decimate-by-8 FIR filter. + // 2,000,000 Hz / 8 = 250,000 Hz target sample rate. + // Used to avoid SD Card bottlenecks + dsp::decimate::FIRC8xR16x24FS4Decim8 decim_0{}; + + std::array dst_buffer_data{}; + const buffer_c16_t dst_buffer{dst_buffer_data.data(), dst_buffer_data.size()}; + + // --- Energy sliding window for threshold detection --- + static constexpr size_t WINDOW_SIZE = 64; + uint32_t window_buf[WINDOW_SIZE]{}; + size_t window_idx{0}; + uint32_t window_sum{0}; + + std::atomic flush_pending{false}; + size_t flush_start_idx{0}; + + // Pre-trigger IQ ring buffer + // IQ_RING_SAMPLES = 2048 provides ~8ms pre-roll buffer (2048 / 250kHz). + // This captures OOK preambles efficiently while maintaining strict 8KB flush size to prevent + // Out-Of-Memory (OOM) / HardFault crashes on M0 CaptureThread, which has only 16KB buffer constraint. + static constexpr size_t IQ_RING_SAMPLES = 2048; + std::array iq_ring{}; + size_t iq_ring_idx{0}; + + uint32_t energy_threshold{5000}; + + std::atomic hunting{false}; + std::atomic stream_active{false}; + std::atomic stream_close_requested{false}; + + bool configured{false}; + + // State machine tracking signal detection lifecycle + enum class HuntState { + // Awaiting energy threshold crossing + IDLE, + // Trigger sent; waiting for CaptureConfigMessage from M0 to create stream + AWAITING_STREAM, + // Stream active; writing live decimated samples + RECORDING, + // Energy dropped below threshold; checking if signal returns + HANGTIME, + // Stop sent; waiting for CaptureConfigMessage(nullptr) from M0 to destroy stream + AWAITING_CLOSE + }; + + std::atomic hunt_state{HuntState::IDLE}; + + uint32_t hangtime_counter{0}; + // Dynamic hangtime configuration: holds sample count in post-decimation domain (250 kHz rate). + // Converted from milliseconds: hangtime_ms * 250 samples/ms. + // Set dynamically via HunterConfigMessage to allow configurable silence tolerance. + uint32_t hangtime_samples_limit{0}; + + std::unique_ptr stream{}; + + void configure(); + void reset_hunt_state(); + + BasebandThread baseband_thread{baseband_fs, this, baseband::Direction::Receive}; + RSSIThread rssi_thread{}; +}; + +#endif /*__PROC_SIGNAL_HUNTER_H__*/ diff --git a/firmware/common/message.hpp b/firmware/common/message.hpp index 1ac8f27e6..2c2382277 100644 --- a/firmware/common/message.hpp +++ b/firmware/common/message.hpp @@ -165,6 +165,9 @@ class Message { VorRxConfigure = 107, VorRxStatusData = 108, VorTxConfigure = 109, + HunterConfig = 110, + HunterTrigger = 111, + HunterStop = 112, MAX }; @@ -1984,4 +1987,26 @@ class FlexTosendMessage : public Message { uint8_t msg[240] = {0}; }; +class HunterConfigMessage : public Message { + public: + uint32_t energy_threshold{5000}; + uint32_t hangtime_ms{500}; + bool start{false}; + constexpr HunterConfigMessage() + : Message{ID::HunterConfig} {} +}; + +class HunterTriggerMessage : public Message { + public: + uint32_t energy{0}; + constexpr HunterTriggerMessage() + : Message{ID::HunterTrigger} {} +}; + +class HunterStopMessage : public Message { + public: + constexpr HunterStopMessage() + : Message{ID::HunterStop} {} +}; + #endif /*__MESSAGE_H__*/ diff --git a/firmware/common/spi_image.hpp b/firmware/common/spi_image.hpp index a7350c83f..ed6ecffe7 100644 --- a/firmware/common/spi_image.hpp +++ b/firmware/common/spi_image.hpp @@ -139,6 +139,8 @@ constexpr image_tag_t image_tag_noop{'P', 'N', 'O', 'P'}; constexpr image_tag_t image_tag_hackrf{'H', 'R', 'F', '1'}; +constexpr image_tag_t image_tag_signal_hunter{'H', 'U', 'N', 'T'}; + struct chunk_t { const image_tag_t tag; const uint32_t length;