Add FPV DETECT external app with Auto-scan FPV Presence Receiver (#3084)

* fpv_rx external app implemented

* FPV DETECT: PR feedback and Copilot suggestions

- Rename app from 'FPV RX' to 'FPV DETECT' (menu and title)
- Add fpv_rx to EXTAPPLIST in external.cmake so app is included in build
- Set external_apps_address_end = 0xAE010000 in external_app_info.py
- Fix clang-format in ui_fpv_rx.cpp (update_visual_state block)
- Re-sample adjacent channels during candidate verification so
  neighbor_margin lock decision uses current measurements instead of
  stale channel_memory_ from scan (reduces false/missed locks)

Made-with: Cursor

* Rename fpv_rx to fpv_detect (files, dir, app id)

- Rename directory external/fpv_rx -> external/fpv_detect
- Rename ui_fpv_rx.{cpp,hpp} -> ui_fpv_detect.{cpp,hpp}
- Update external.cmake and external.ld for fpv_detect
- Namespace fpv_rx -> fpv_detect, class FpvRxView -> FpvDetectView
- Section/symbol .app_fpv_rx -> .app_fpv_detect

Made-with: Cursor
This commit is contained in:
berkeozkir
2026-03-16 05:28:07 +03:00
committed by GitHub
parent b273165a0c
commit 52dd2ea2bb
6 changed files with 946 additions and 4 deletions
+7 -2
View File
@@ -305,8 +305,8 @@ set(EXTCPPSRC
external/morseradiotx/main.cpp
external/morseradiotx/ui_morse_radiotx.cpp
external/keeloqtx/main.cpp
external/keeloqtx/ui_keeloqtx.cpp
external/keeloqtx/main.cpp
external/keeloqtx/ui_keeloqtx.cpp
#rtty_rx
external/rtty_rx/main.cpp
external/rtty_rx/ui_rtty_rx.cpp
@@ -328,6 +328,10 @@ set(EXTCPPSRC
#kiss_tnc
external/kiss_tnc/main.cpp
external/kiss_tnc/ui_kiss_tnc.cpp
#fpv_detect
external/fpv_detect/main.cpp
external/fpv_detect/ui_fpv_detect.cpp
)
set(EXTAPPLIST
@@ -388,6 +392,7 @@ set(EXTAPPLIST
gfxeq
waterfall_designer
detector_rx
fpv_detect
spaceinv
blackjack
battleship
+7 -1
View File
@@ -103,7 +103,7 @@ MEMORY
ram_external_app_same_tx (rwx) : org = 0xADFE0000, len = 32k
ram_external_app_kiss_tnc (rwx) : org = 0xADFF0000, len = 32k
ram_external_app_epirb_tx (rwx) : org = 0xAE000000, len = 32k
ram_external_app_fpv_detect (rwx) : org = 0xAE010000, len = 32k
}
SECTIONS
@@ -588,5 +588,11 @@ SECTIONS
KEEP(*(.external_app.app_epirb_tx.application_information));
*(*ui*external_app*epirb_tx*);
} > ram_external_app_epirb_tx
.external_app_fpv_detect : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_fpv_detect.application_information));
*(*ui*external_app*fpv_detect*);
} > ram_external_app_fpv_detect
}
+65
View File
@@ -0,0 +1,65 @@
// Author: berkeozkir (Berke Özkır)
#include "ui.hpp"
#include "ui_fpv_detect.hpp"
#include "ui_navigation.hpp"
#include "external_app.hpp"
namespace ui::external_app::fpv_detect {
void initialize_app(ui::NavigationView& nav) {
nav.push<FpvDetectView>();
}
} // namespace ui::external_app::fpv_detect
extern "C" {
__attribute__((section(".external_app.app_fpv_detect.application_information"), used)) application_information_t _application_information_fpv_detect = {
/*.memory_location = */ (uint8_t*)0x00000000,
/*.externalAppEntry = */ ui::external_app::fpv_detect::initialize_app,
/*.header_version = */ CURRENT_HEADER_VERSION,
/*.app_version = */ VERSION_MD5,
/*.app_name = */ "FPV DETECT",
/*.bitmap_data = */
{
0x00,
0x00,
0xC0,
0x03,
0x20,
0x04,
0x10,
0x08,
0x48,
0x12,
0xA4,
0x25,
0x54,
0x2A,
0xA8,
0x15,
0x54,
0x2A,
0xA4,
0x25,
0x48,
0x12,
0x10,
0x08,
0x20,
0x04,
0xC0,
0x03,
0x00,
0x00,
0x00,
0x00,
},
/*.icon_color = */ ui::Color::cyan().v,
/*.menu_location = */ app_location_t::RX,
/*.desired_menu_position = */ -1,
/*.m4_app_tag = portapack::spi_flash::image_tag_wfm_audio */ {'P', 'W', 'F', 'M'},
/*.m4_app_offset = */ 0x00000000,
};
}
@@ -0,0 +1,666 @@
// Author: berkeozkir (Berke Özkır)
/*
* FPV RX — how frequency search and lock work
*
* 1. Power metric: We use channelized power (baseband IQ magnitude² in the
* capture bandwidth), not raw RF RSSI. Stats come from ChannelStatsCollector
* over filtered IQ, so we see power in the tuned channel, not wideband.
*
* 2. Scanning: We step through FPV bands/channels (A/B/E/F/R, 8 ch each). When
* power on the current channel exceeds the detect threshold, we enter
* "Candidate" and run verification — we do not lock on a single peak.
*
* 3. Verification (making sure the drone is on that freq):
* - Multiple samples: verify_hits / verify_misses over several updates.
* - Confidence score: combines average above threshold, peak margin,
* hit count, and crucially neighbor_margin = center power max(adjacent ch).
* - Neighbor check: we only lock if the center channel is at least
* MIN_NEIGHBOR_MARGIN_FOR_LOCK_DB above both adjacent channels. That
* avoids locking on wide/noise that looks strong on many channels.
* During Candidate we re-sample left/right adjacent channels so the
* margin is based on current measurements, not stale scan data.
* - Lock requires: enough hits, confidence ≥ LOCK_CONFIDENCE_MIN, and
* center stronger than neighbors (strong lock path has same requirement).
*
* 4. Lock hold: Once locked, we only unlock when power stays below
* unlock_threshold_db() long enough to drain lock_hold_ (down from
* LOCK_HOLD_MAX), so brief fades don't drop lock.
*/
#include "ui_fpv_detect.hpp"
#include <cstdio>
#include "baseband_api.hpp"
#include "oversample.hpp"
using namespace portapack;
namespace ui::external_app::fpv_detect {
static constexpr uint8_t SCAN_DWELL_FRAMES = 1;
static constexpr uint8_t VERIFY_SAMPLE_TARGET = 5;
static constexpr uint8_t VERIFY_MIN_HITS = 4;
static constexpr uint8_t STRONG_LOCK_PEAK_MARGIN_DB = 8;
static constexpr uint8_t LOCK_HOLD_MAX = 12;
static constexpr uint8_t LOCK_CONFIDENCE_MIN = 72;
/** Center channel must be at least this many dB above adjacent channels to lock.
* Ensures we lock on a real FPV carrier (narrow peak) rather than wide/noise. */
static constexpr int32_t MIN_NEIGHBOR_MARGIN_FOR_LOCK_DB = 3;
static constexpr uint8_t BEEP_GUARD_FRAMES = 8;
int32_t FpvDetectView::clamp_value(int32_t value, int32_t low, int32_t high) {
if (value < low) return low;
if (value > high) return high;
return value;
}
int32_t FpvDetectView::map_value(int32_t value, int32_t from_low, int32_t from_high, int32_t to_low, int32_t to_high) {
const auto clamped = clamp_value(value, from_low, from_high);
return to_low + (clamped - from_low) * (to_high - to_low) / (from_high - from_low);
}
void FpvDetectView::focus() {
field_lna.focus();
}
FpvDetectView::~FpvDetectView() {
shared_memory.request_m4_performance_counter = 0;
receiver_model.disable();
audio::output::stop();
baseband::shutdown();
}
uint8_t FpvDetectView::channel_index(uint8_t band, uint8_t ch) const {
return static_cast<uint8_t>(band * FPV_CHANNELS_PER_BAND + ch);
}
uint8_t FpvDetectView::current_channel_index() const {
return channel_index(scan_band, scan_ch);
}
int16_t FpvDetectView::detect_threshold_db() const {
return static_cast<int16_t>(detect_threshold_db_);
}
int16_t FpvDetectView::unlock_threshold_db() const {
return static_cast<int16_t>(detect_threshold_db() - 6);
}
int16_t FpvDetectView::candidate_average_db() const {
if (!verify_samples_) {
return -120;
}
return static_cast<int16_t>(verify_sum_db_ / verify_samples_);
}
int16_t FpvDetectView::neighbor_best_db() const {
int16_t best = -120;
if (candidate_ch_ > 0) {
const auto& left = channel_memory_[channel_index(candidate_band_, static_cast<uint8_t>(candidate_ch_ - 1))];
if (left.last_db > best) {
best = left.last_db;
}
}
if ((candidate_ch_ + 1) < FPV_CHANNELS_PER_BAND) {
const auto& right = channel_memory_[channel_index(candidate_band_, static_cast<uint8_t>(candidate_ch_ + 1))];
if (right.last_db > best) {
best = right.last_db;
}
}
return best;
}
void FpvDetectView::request_event_beep(uint32_t hz, uint32_t duration_ms) {
if ((frame_counter_ - last_beep_frame_) < BEEP_GUARD_FRAMES) {
return;
}
last_beep_frame_ = frame_counter_;
baseband::request_audio_beep(hz, 24000, duration_ms);
}
void FpvDetectView::retune_to(uint8_t band, uint8_t ch) {
scan_band = band;
scan_ch = ch;
freq_ = fpv_frequencies[band][ch];
receiver_model.set_target_frequency(freq_);
update_freq_display();
}
void FpvDetectView::step_scan() {
if (band_mode < FPV_NUM_BANDS) {
scan_band = band_mode;
scan_ch = static_cast<uint8_t>((scan_ch + 1) % FPV_CHANNELS_PER_BAND);
} else {
scan_ch = static_cast<uint8_t>(scan_ch + 1);
if (scan_ch >= FPV_CHANNELS_PER_BAND) {
scan_ch = 0;
scan_band = static_cast<uint8_t>(scan_band + 1);
if (scan_band >= FPV_NUM_BANDS) {
scan_band = 0;
}
}
}
retune_to(scan_band, scan_ch);
scan_dwell_frames_ = SCAN_DWELL_FRAMES;
}
void FpvDetectView::reset_detector(bool retune_current) {
detect_state_ = DetectState::Scanning;
candidate_band_ = scan_band;
candidate_ch_ = scan_ch;
verify_samples_ = 0;
verify_hits_ = 0;
verify_misses_ = 0;
verify_sum_db_ = 0;
verify_peak_db_ = -120;
candidate_confidence_ = 0;
lock_hold_ = 0;
neighbor_phase_ = 0;
neighbors_fresh_ = false;
scan_dwell_frames_ = 0;
update_state_badge();
update_confidence_text();
update_status_text();
update_detail_text();
update_visual_state();
if (retune_current) {
retune_to(scan_band, scan_ch);
scan_dwell_frames_ = SCAN_DWELL_FRAMES;
}
}
bool FpvDetectView::is_possible_analog_carrier(const ChannelStatistics& statistics) const {
return statistics.max_db >= detect_threshold_db();
}
uint8_t FpvDetectView::compute_candidate_confidence() const {
if (!verify_samples_) {
return 0;
}
const int32_t avg_db = candidate_average_db();
const int32_t center_margin = avg_db - detect_threshold_db();
const int32_t peak_margin = verify_peak_db_ - detect_threshold_db();
const int32_t neighbor_margin = avg_db - neighbor_best_db();
int32_t score = 18;
score += center_margin * 7;
score += peak_margin * 4;
score += static_cast<int32_t>(verify_hits_) * 5;
score -= static_cast<int32_t>(verify_misses_) * 10;
if (neighbor_margin >= 6) {
score += 12;
} else if (neighbor_margin >= 3) {
score += 6;
} else if (neighbor_margin <= 0) {
score -= 8;
}
const auto& mem = channel_memory_[channel_index(candidate_band_, candidate_ch_)];
if (mem.hits >= 2) {
score += 4;
}
return static_cast<uint8_t>(clamp_value(score, 0, 99));
}
void FpvDetectView::enter_candidate(const ChannelStatistics& statistics) {
detect_state_ = DetectState::Candidate;
candidate_band_ = scan_band;
candidate_ch_ = scan_ch;
verify_samples_ = 1;
verify_hits_ = 1;
verify_misses_ = 0;
verify_sum_db_ = statistics.max_db;
verify_peak_db_ = statistics.max_db;
candidate_confidence_ = compute_candidate_confidence();
neighbor_phase_ = 0;
neighbors_fresh_ = false;
retune_to(candidate_band_, candidate_ch_);
update_state_badge();
update_confidence_text();
update_status_text();
update_detail_text();
update_visual_state();
request_event_beep(1150, 60);
}
void FpvDetectView::evaluate_candidate_sample(const ChannelStatistics& statistics) {
if (detect_state_ != DetectState::Candidate) {
return;
}
++verify_samples_;
verify_sum_db_ += statistics.max_db;
if (statistics.max_db > verify_peak_db_) {
verify_peak_db_ = statistics.max_db;
}
if (statistics.max_db >= detect_threshold_db()) {
++verify_hits_;
} else {
++verify_misses_;
}
candidate_confidence_ = compute_candidate_confidence();
update_confidence_text();
update_detail_text();
/* Refresh adjacent channel power so neighbor_best_db() uses current
* measurements instead of stale scan data. Do this before any lock decision. */
if (verify_samples_ >= 2 && !neighbors_fresh_) {
if (candidate_ch_ > 0) {
neighbor_phase_ = 1;
retune_to(candidate_band_, static_cast<uint8_t>(candidate_ch_ - 1));
} else {
neighbor_phase_ = 2;
retune_to(candidate_band_, static_cast<uint8_t>(candidate_ch_ + 1));
}
return;
}
/* Lock decisions use neighbor margin; only run when we have fresh neighbor data. */
if (!neighbors_fresh_) {
return;
}
const bool strong_lock =
verify_samples_ >= 3 &&
verify_hits_ >= 3 &&
verify_peak_db_ >= (detect_threshold_db() + STRONG_LOCK_PEAK_MARGIN_DB) &&
candidate_confidence_ >= 80 &&
(candidate_average_db() - neighbor_best_db()) >= MIN_NEIGHBOR_MARGIN_FOR_LOCK_DB;
if (strong_lock) {
enter_lock();
return;
}
if (verify_samples_ < VERIFY_SAMPLE_TARGET) {
return;
}
const bool center_stronger_than_neighbors =
(candidate_average_db() - neighbor_best_db()) >= MIN_NEIGHBOR_MARGIN_FOR_LOCK_DB;
if (verify_hits_ >= VERIFY_MIN_HITS && candidate_confidence_ >= LOCK_CONFIDENCE_MIN &&
center_stronger_than_neighbors) {
enter_lock();
return;
}
detect_state_ = DetectState::Scanning;
candidate_confidence_ = static_cast<uint8_t>(candidate_confidence_ / 2);
verify_samples_ = 0;
verify_hits_ = 0;
verify_misses_ = 0;
verify_sum_db_ = 0;
verify_peak_db_ = -120;
scan_dwell_frames_ = 0;
update_state_badge();
update_confidence_text();
update_status_text();
update_detail_text();
update_visual_state();
}
void FpvDetectView::enter_lock() {
detect_state_ = DetectState::Locked;
lock_hold_ = LOCK_HOLD_MAX;
candidate_confidence_ = static_cast<uint8_t>(clamp_value(candidate_confidence_, LOCK_CONFIDENCE_MIN, 99));
retune_to(candidate_band_, candidate_ch_);
update_state_badge();
update_confidence_text();
update_status_text();
update_detail_text();
update_visual_state();
request_event_beep(1850, 180);
}
void FpvDetectView::update_lock(const ChannelStatistics& statistics) {
if (detect_state_ != DetectState::Locked) {
return;
}
if (statistics.max_db >= unlock_threshold_db()) {
if (lock_hold_ < LOCK_HOLD_MAX) {
++lock_hold_;
}
if (candidate_confidence_ < 99) {
++candidate_confidence_;
}
} else {
if (lock_hold_ > 0) {
--lock_hold_;
}
if (candidate_confidence_ > 0) {
--candidate_confidence_;
}
}
update_confidence_text();
update_detail_text();
if (lock_hold_ > 0) {
return;
}
detect_state_ = DetectState::Scanning;
candidate_confidence_ = 0;
verify_samples_ = 0;
verify_hits_ = 0;
verify_misses_ = 0;
verify_sum_db_ = 0;
verify_peak_db_ = -120;
scan_dwell_frames_ = 0;
update_state_badge();
update_confidence_text();
update_status_text();
update_detail_text();
update_visual_state();
request_event_beep(650, 90);
}
void FpvDetectView::update_freq_display() {
char buf[28];
std::snprintf(buf, sizeof(buf), "%c%d %ld MHz",
band_labels[scan_band],
static_cast<int>(scan_ch + 1),
static_cast<long>(freq_ / 1000000LL));
text_freq.set(buf);
}
void FpvDetectView::update_state_badge() {
switch (detect_state_) {
case DetectState::Scanning:
text_state.set("SCANNING");
break;
case DetectState::Candidate:
text_state.set("VERIFY FPV");
break;
case DetectState::Locked:
text_state.set("DRONE FOUND");
break;
}
}
void FpvDetectView::update_confidence_text() {
char buf[16];
std::snprintf(buf, sizeof(buf), "Conf %u%%", static_cast<unsigned>(candidate_confidence_));
text_confidence.set(buf);
}
void FpvDetectView::update_status_text() {
char buf[40];
switch (detect_state_) {
case DetectState::Scanning:
if (band_mode < FPV_NUM_BANDS) {
std::snprintf(buf, sizeof(buf), "Scanning band %c for analog FPV", band_labels[band_mode]);
} else {
std::snprintf(buf, sizeof(buf), "Scanning all FPV bands");
}
break;
case DetectState::Candidate:
std::snprintf(buf, sizeof(buf), "VERIFYING %c%d %ld MHz",
band_labels[candidate_band_],
static_cast<int>(candidate_ch_ + 1),
static_cast<long>(fpv_frequencies[candidate_band_][candidate_ch_] / 1000000LL));
break;
case DetectState::Locked:
std::snprintf(buf, sizeof(buf), "!!! DRONE FOUND !!! %c%d %ld",
band_labels[candidate_band_],
static_cast<int>(candidate_ch_ + 1),
static_cast<long>(fpv_frequencies[candidate_band_][candidate_ch_] / 1000000LL));
break;
}
text_status.set(buf);
}
void FpvDetectView::update_detail_text() {
char buf[40];
switch (detect_state_) {
case DetectState::Scanning:
std::snprintf(buf, sizeof(buf), "Need >= %d dB, stable dwell", static_cast<int>(detect_threshold_db()));
break;
case DetectState::Candidate:
std::snprintf(buf, sizeof(buf), "avg %d peak %d hits %u/%u",
static_cast<int>(candidate_average_db()),
static_cast<int>(verify_peak_db_),
static_cast<unsigned>(verify_hits_),
static_cast<unsigned>(verify_samples_));
break;
case DetectState::Locked:
std::snprintf(buf, sizeof(buf), "LOCKED %c%d conf %u%% hold %u",
band_labels[candidate_band_],
static_cast<int>(candidate_ch_ + 1),
static_cast<unsigned>(candidate_confidence_),
static_cast<unsigned>(lock_hold_));
break;
}
text_detail.set(buf);
}
void FpvDetectView::update_visual_state() {
auto* theme = Theme::getInstance();
/*
* Conservative styling for cross-branch compatibility.
* If your branch has explicit blue/red banner theme styles,
* swap the styles in this function only.
*/
auto normal_style = theme->bg_darkest;
auto attention_style = theme->fg_red;
switch (detect_state_) {
case DetectState::Scanning:
text_state.set_style(normal_style);
text_status.set_style(normal_style);
break;
case DetectState::Candidate:
text_state.set_style(normal_style);
text_status.set_style(normal_style);
break;
case DetectState::Locked:
text_state.set_style(attention_style);
text_status.set_style(attention_style);
break;
}
freq_stats_rssi.set_style(normal_style);
freq_stats_db.set_style(normal_style);
text_detail.set_style(normal_style);
text_confidence.set_style(normal_style);
}
void FpvDetectView::update_stats_text(const ChannelStatistics& statistics) {
rssi_graph.add_values(rssi.get_min(), rssi.get_avg(), rssi.get_max(), statistics.max_db);
auto& mem = channel_memory_[current_channel_index()];
mem.last_db = statistics.max_db;
if (statistics.max_db > mem.peak_db) {
mem.peak_db = statistics.max_db;
}
if (statistics.max_db >= detect_threshold_db()) {
if (mem.hits < 255) {
++mem.hits;
}
}
mem.confidence = candidate_confidence_;
if (last_max_db_ != statistics.max_db) {
last_max_db_ = statistics.max_db;
char power_buf[20];
std::snprintf(power_buf, sizeof(power_buf), "PWR %d dB", static_cast<int>(statistics.max_db));
freq_stats_db.set(power_buf);
rssi.set_db(statistics.max_db);
}
const uint8_t graph_min = rssi_graph.get_graph_min();
const uint8_t graph_avg = rssi_graph.get_graph_avg();
const uint8_t graph_max = rssi_graph.get_graph_max();
if (last_min_rssi_ != graph_min || last_avg_rssi_ != graph_avg || last_max_rssi_ != graph_max) {
last_min_rssi_ = graph_min;
last_avg_rssi_ = graph_avg;
last_max_rssi_ = graph_max;
char rssi_buf[22];
std::snprintf(rssi_buf, sizeof(rssi_buf), "RSSI %u/%u/%u",
static_cast<unsigned>(graph_min),
static_cast<unsigned>(graph_avg),
static_cast<unsigned>(graph_max));
freq_stats_rssi.set(rssi_buf);
}
}
void FpvDetectView::on_timer() {
++frame_counter_;
if (detect_state_ != DetectState::Scanning) {
return;
}
if (scan_dwell_frames_ > 0) {
--scan_dwell_frames_;
return;
}
step_scan();
}
FpvDetectView::FpvDetectView(NavigationView& nav)
: nav_{nav} {
add_children({
&labels,
&field_lna,
&field_vga,
&field_rf_amp,
&field_volume,
&field_band,
&text_freq,
&text_state,
&text_confidence,
&text_detect_label,
&field_detect_threshold,
&freq_stats_rssi,
&freq_stats_db,
&text_status,
&text_detail,
&rssi,
&rssi_graph,
});
rssi.set_vertical_rssi(true);
rssi.set_peak(true, 3000);
rssi_graph.set_nb_columns(256);
field_detect_threshold.set_value(detect_threshold_db_);
field_detect_threshold.on_change = [this](int32_t v) {
detect_threshold_db_ = v;
update_detail_text();
};
field_band.on_change = [this](size_t, int32_t value) {
band_mode = static_cast<uint8_t>(value);
scan_band = (band_mode < FPV_NUM_BANDS) ? band_mode : 0;
scan_ch = 0;
reset_detector(true);
};
change_mode();
field_band.set_selected_index(FPV_AUTO_SCAN_MODE);
reset_detector(true);
}
void FpvDetectView::on_statistics_update(const ChannelStatistics& statistics) {
const bool is_sampling_neighbor =
(detect_state_ == DetectState::Candidate && (neighbor_phase_ == 1 || neighbor_phase_ == 2));
if (!is_sampling_neighbor) {
update_stats_text(statistics);
}
switch (detect_state_) {
case DetectState::Scanning:
if (is_possible_analog_carrier(statistics)) {
enter_candidate(statistics);
}
break;
case DetectState::Candidate:
if (neighbor_phase_ == 1) {
const auto idx = channel_index(candidate_band_, static_cast<uint8_t>(candidate_ch_ - 1));
channel_memory_[idx].last_db = statistics.max_db;
if (candidate_ch_ + 1 < FPV_CHANNELS_PER_BAND) {
neighbor_phase_ = 2;
retune_to(candidate_band_, static_cast<uint8_t>(candidate_ch_ + 1));
} else {
neighbor_phase_ = 0;
neighbors_fresh_ = true;
retune_to(candidate_band_, candidate_ch_);
}
break;
}
if (neighbor_phase_ == 2) {
const auto idx = channel_index(candidate_band_, static_cast<uint8_t>(candidate_ch_ + 1));
channel_memory_[idx].last_db = statistics.max_db;
neighbor_phase_ = 0;
neighbors_fresh_ = true;
retune_to(candidate_band_, candidate_ch_);
break;
}
evaluate_candidate_sample(statistics);
break;
case DetectState::Locked:
update_lock(statistics);
break;
}
}
size_t FpvDetectView::change_mode() {
audio::output::stop();
receiver_model.disable();
baseband::shutdown();
audio_sampling_rate = audio::Rate::Hz_24000;
baseband::run_image(portapack::spi_flash::image_tag_capture);
receiver_model.set_modulation(ReceiverModel::Mode::Capture);
baseband::set_sample_rate(FPV_RX_BW, get_oversample_rate(FPV_RX_BW));
auto actual_sampling_rate = get_actual_sample_rate(FPV_RX_BW);
receiver_model.set_sampling_rate(actual_sampling_rate);
receiver_model.set_baseband_bandwidth(filter_bandwidth_for_sampling_rate(actual_sampling_rate));
audio::set_rate(audio_sampling_rate);
audio::output::start();
receiver_model.set_headphone_volume(receiver_model.headphone_volume());
receiver_model.enable();
return 0;
}
} // namespace ui::external_app::fpv_detect
@@ -0,0 +1,200 @@
// Author: berkeozkir (Berke Özkır)
#ifndef _UI_FPV_DETECT
#define _UI_FPV_DETECT
#include <array>
#include <cstdint>
#include "analog_audio_app.hpp"
#include "app_settings.hpp"
#include "audio.hpp"
#include "baseband_api.hpp"
#include "radio_state.hpp"
#include "receiver_model.hpp"
#include "string_format.hpp"
#include "ui.hpp"
#include "ui_receiver.hpp"
#include "ui_spectrum.hpp"
namespace ui::external_app::fpv_detect {
static constexpr uint8_t FPV_NUM_BANDS = 5;
static constexpr uint8_t FPV_CHANNELS_PER_BAND = 8;
static constexpr uint8_t FPV_TOTAL_CHANNELS = FPV_NUM_BANDS * FPV_CHANNELS_PER_BAND;
static constexpr uint8_t FPV_AUTO_SCAN_MODE = FPV_NUM_BANDS;
static constexpr int64_t fpv_frequencies[FPV_NUM_BANDS][FPV_CHANNELS_PER_BAND] = {
/* Band A */ {5865000000LL, 5845000000LL, 5825000000LL, 5805000000LL, 5785000000LL, 5765000000LL, 5745000000LL, 5725000000LL},
/* Band B */ {5733000000LL, 5752000000LL, 5771000000LL, 5790000000LL, 5809000000LL, 5828000000LL, 5847000000LL, 5866000000LL},
/* Band E */ {5705000000LL, 5685000000LL, 5665000000LL, 5645000000LL, 5885000000LL, 5905000000LL, 5925000000LL, 5945000000LL},
/* Band F */ {5740000000LL, 5760000000LL, 5780000000LL, 5800000000LL, 5820000000LL, 5840000000LL, 5860000000LL, 5880000000LL},
/* Band R */ {5658000000LL, 5695000000LL, 5732000000LL, 5769000000LL, 5806000000LL, 5843000000LL, 5880000000LL, 5917000000LL},
};
static constexpr char band_labels[] = {'A', 'B', 'E', 'F', 'R'};
#define FPV_RX_BW 750000
class FpvDetectView : public View {
public:
explicit FpvDetectView(NavigationView& nav);
~FpvDetectView();
void focus() override;
std::string title() const override { return "FPV DETECT"; };
private:
enum class DetectState : uint8_t {
Scanning,
Candidate,
Locked,
};
struct ChannelMemory {
int16_t last_db = -120;
int16_t peak_db = -120;
uint8_t hits = 0;
uint8_t confidence = 0;
};
NavigationView& nav_;
RxRadioState radio_state_{};
static int32_t clamp_value(int32_t value, int32_t low, int32_t high);
static int32_t map_value(int32_t value, int32_t from_low, int32_t from_high, int32_t to_low, int32_t to_high);
size_t change_mode();
void on_statistics_update(const ChannelStatistics& statistics);
void on_timer();
void retune_to(uint8_t band, uint8_t ch);
void step_scan();
void reset_detector(bool retune_current);
bool is_possible_analog_carrier(const ChannelStatistics& statistics) const;
void enter_candidate(const ChannelStatistics& statistics);
void evaluate_candidate_sample(const ChannelStatistics& statistics);
void enter_lock();
void update_lock(const ChannelStatistics& statistics);
uint8_t current_channel_index() const;
uint8_t channel_index(uint8_t band, uint8_t ch) const;
uint8_t compute_candidate_confidence() const;
int16_t detect_threshold_db() const;
int16_t unlock_threshold_db() const;
int16_t candidate_average_db() const;
int16_t neighbor_best_db() const;
void request_event_beep(uint32_t hz, uint32_t duration_ms);
void update_freq_display();
void update_state_badge();
void update_confidence_text();
void update_status_text();
void update_detail_text();
void update_stats_text(const ChannelStatistics& statistics);
void update_visual_state();
DetectState detect_state_ = DetectState::Scanning;
uint8_t scan_band = 0;
uint8_t scan_ch = 0;
uint8_t band_mode = FPV_AUTO_SCAN_MODE;
uint8_t candidate_band_ = 0;
uint8_t candidate_ch_ = 0;
uint8_t verify_samples_ = 0;
uint8_t verify_hits_ = 0;
uint8_t verify_misses_ = 0;
int32_t verify_sum_db_ = 0;
int16_t verify_peak_db_ = -120;
uint8_t candidate_confidence_ = 0;
uint8_t lock_hold_ = 0;
/** During Candidate: 0=center, 1=left neighbor, 2=right neighbor. Used to
* re-sample adjacent channels so neighbor_margin uses current measurements. */
uint8_t neighbor_phase_ = 0;
bool neighbors_fresh_ = false;
uint8_t scan_dwell_frames_ = 0;
uint32_t frame_counter_ = 0;
uint32_t last_beep_frame_ = 0;
int16_t last_max_db_ = -127;
uint8_t last_min_rssi_ = 0;
uint8_t last_avg_rssi_ = 0;
uint8_t last_max_rssi_ = 0;
std::array<ChannelMemory, FPV_TOTAL_CHANNELS> channel_memory_{};
rf::Frequency freq_ = {fpv_frequencies[0][0]};
int32_t detect_threshold_db_ = -38;
audio::Rate audio_sampling_rate = audio::Rate::Hz_48000;
app_settings::SettingsManager settings_{
"rx_fpv",
app_settings::Mode::RX,
{
{"detect_threshold"sv, &detect_threshold_db_},
}};
Labels labels{
{{UI_POS_X(0), UI_POS_Y(0)}, "LNA: VGA: AMP: ", Theme::getInstance()->fg_light->foreground},
{{UI_POS_X_RIGHT(6), UI_POS_Y(0)}, "VOL: ", Theme::getInstance()->fg_light->foreground},
};
LNAGainField field_lna{{UI_POS_X(4), UI_POS_Y(0)}};
VGAGainField field_vga{{UI_POS_X(11), UI_POS_Y(0)}};
RFAmpField field_rf_amp{{UI_POS_X(18), UI_POS_Y(0)}};
AudioVolumeField field_volume{{UI_POS_X_RIGHT(2), UI_POS_Y(0)}};
OptionsField field_band{
{UI_POS_X(0), UI_POS_Y(1)},
9,
{
{"Band A ", 0},
{"Band B ", 1},
{"Band E ", 2},
{"Band F ", 3},
{"Band R ", 4},
{"AutoScan", FPV_AUTO_SCAN_MODE},
}};
Text text_freq{{UI_POS_X_RIGHT(20), UI_POS_Y(1), UI_POS_WIDTH(20), UI_POS_DEFAULT_HEIGHT}, ""};
Text text_state{{UI_POS_X(0), UI_POS_Y(2), UI_POS_WIDTH(12), UI_POS_DEFAULT_HEIGHT}, "SCANNING"};
Text text_confidence{{UI_POS_X(12), UI_POS_Y(2), UI_POS_WIDTH(8), UI_POS_DEFAULT_HEIGHT}, "Conf 0%"};
Text text_detect_label{{UI_POS_X_RIGHT(10), UI_POS_Y(2), UI_POS_WIDTH(5), UI_POS_DEFAULT_HEIGHT}, "Thr>"};
NumberField field_detect_threshold{
{UI_POS_X_RIGHT(5), UI_POS_Y(2)},
4,
{-100, 20},
1,
' ',
};
Text freq_stats_rssi{{UI_POS_X(0), UI_POS_Y(3), UI_POS_WIDTH(15), UI_POS_DEFAULT_HEIGHT}, "RSSI 0/0/0"};
Text freq_stats_db{{UI_POS_X_RIGHT(14), UI_POS_Y(3), UI_POS_WIDTH(14), UI_POS_DEFAULT_HEIGHT}, "PWR -120 dB"};
Text text_status{{UI_POS_X(0), UI_POS_Y(4), UI_POS_WIDTH(30), UI_POS_DEFAULT_HEIGHT}, "SCANNING FOR ANALOG FPV"};
Text text_detail{{UI_POS_X(0), UI_POS_Y(5), UI_POS_WIDTH(30), UI_POS_DEFAULT_HEIGHT}, "Waiting for FPV-like carrier"};
RSSIGraph rssi_graph{{UI_POS_X(0), UI_POS_Y(6), UI_POS_WIDTH_REMAINING(5), UI_POS_HEIGHT_REMAINING(7)}};
RSSI rssi{{UI_POS_X_RIGHT(5), UI_POS_Y(6), UI_POS_WIDTH(5), UI_POS_HEIGHT_REMAINING(7)}};
MessageHandlerRegistration message_handler_stats{
Message::ID::ChannelStatistics,
[this](const Message* const p) {
this->on_statistics_update(static_cast<const ChannelStatisticsMessage*>(p)->statistics);
}};
MessageHandlerRegistration message_handler_frame_sync{
Message::ID::DisplayFrameSync,
[this](const Message* const) {
this->on_timer();
}};
};
} // namespace ui::external_app::fpv_detect
#endif
+1 -1
View File
@@ -24,4 +24,4 @@
# external app address ranges below must match those in linker file "external.ld"
maximum_application_size = 32*1024
external_apps_address_start = 0xADB00000
external_apps_address_end = 0xADFC0000
external_apps_address_end = 0xAE020000