Files
mayhem-firmware/firmware/application/external/epirb_rx/ui_epirb_rx.cpp
T
Frederic BORRY c34a6940ca EPIRB RX app rework + EPIRB TX app fixes (#3172)
* First step to EPIRB rx app fix
* Added spike filtering on carrier detection for more robustness.
* Created dedicated packet builder
* First step to fixing UI.
* First step to epirb-rx ui rework.
* Made static methods static members
* Code optim
* Code cleanup + first step to new UI
* Fixed display
* Added BeaconList widget + BeaconDB
* Epirb TX app memory optim
* Fixed OOM when opening Map Display or Locator editor on EPIRB TX app
* First step to tabs + code cleanup to free some space...
* Added spectrum analyzer + fixed display
* Fixed display refresh issue after leaving spectrum.
* More code optim + first step to detail view.
* Fixed float coord display.
* Fixed protocol names
* Fixed location display
* Externalized country base to sd card + more code optim
* Fixed country display
* Fixed path
* Added protocol description and resource manager
* Added main/aux loc device
* Removed specan to save space
* First step to adding QR code
* Used a custom TextArea component instead of Console
* Fixed QR code display for map URL
* Added beacon selection management + fixes
* First step to map/data qr.
* QR tab : added data + detail/map toggle
* Removed sqhelch to save space
* Removed audio to save space
* Removed freq label to save space
* Removed packet timestamp to save space
* Moved frequency values to resource file
* Fixed detection parameters to suite real beacons (slower phase shift time).
* Fixed countdown field size
* Fixed frequency list.
* Fixed detail view on select.
* First step to fix settings.
* Fixed squelch for a 0-99 range.
* Added bch code correction (single bit error).
* More code optim, disable country cache, removed inline from location to reduce code size.
* Removed inline, code formatting.
* Added bch code correction display.
* Fixed focus bug, fixed packet size check. Added BCH1/BCH2 correction test cases in BEACONS.TXT file for EPIRB TX application.
* Fixed merge
* Added countdown setting.
* Added selection display to beacon list
* Restored squelch control. Added countdown reset.
* Code optim on formatSummary
* More code optim
* Made ResourceManager methods static
* Externalized beacon strings to res file
*Removed frame parameter from methods
* Added test beacons for emergency. Removed old res file.
* Fixed resource manager
* Fixed resources. Added emergency display.
* BCH + res fixes
* Fixed beacons
* Fixed app color. Fixed HexId and Serial calculation.
* Fixed frequency list. Comments.
* Code cleanup and comments
* Added slideshow mode to EPIRB TX application. Code comments / cleanup.
* Fixed frame end after wrong code cleanup
* More code optim and comments. Removed touch from beacon list.
* Added beacon selection with encoder on detail view.
* Added beacon paging in header. Fixed timeout display
* Fixed build for PRALINE
* Fixed formatting
* Potential fix for pull request finding
* stop on carriage return
* empty string if unknown
* zeroing data buffer before use
* fix typo
* add include
* fix case if pair == 0
* fix scpectrum_on to spectrum_on
* fix typos
* replaced 3 inlined offset blocks with apply_offset calls
* set_beacon consolidation
2026-05-12 14:00:47 +02:00

601 lines
20 KiB
C++

/*
* Copyright (C) 2024 EPIRB Decoder Implementation
* Copyright (C) 2026 Frederic BORRY - ADRASEC 31
*
* 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 "baseband_api.hpp"
#include "portapack_persistent_memory.hpp"
#include "file_path.hpp"
#include "ui_epirb_rx.hpp"
using namespace portapack;
#include "rtc_time.hpp"
#include "string_format.hpp"
#include "ui.hpp"
#include "message.hpp"
#include "resources.hpp"
namespace ui::external_app::epirb_rx {
// URL templates
#define MAPS_URL_TEMPLATE "https://www.google.com/maps/search/?api=1&query=%s%%2C%s"
#define BEACON_URL_TEMPALTE "https://decoder2.herokuapp.com/decoded/"
#ifndef DISABLE_COUNTRY_CACHE
int CountryManager::cache_count = 0;
Country CountryManager::cache[16];
#endif
// ResourceManager class
ResourceManager* ResourceManager::current = nullptr;
void ResourceManager::destroy() {
if (current != nullptr)
delete current;
current = nullptr;
}
ResourceManager* ResourceManager::getInstance() {
if (current == nullptr) current = new ResourceManager();
return ResourceManager::current;
}
// TextArea class
TextArea::TextArea(
Rect parent_rect)
: Widget{parent_rect} {
}
void TextArea::paint(Painter& painter) {
const auto rect = screen_rect();
const Style& s = has_focus() ? style().invert() : style();
painter.fill_rectangle(rect, s.background);
// We use \t as line separator since \n is used in STR_COLOR_GREEN
auto rows = split_string(content, '\t');
const int line_height = s.font.line_height();
size_t line_idx = 0;
for (auto row : rows) {
painter.draw_string(rect.location() + Point(0, line_idx * line_height), s, row);
line_idx++;
}
}
void TextArea::set_content(std::string_view value) {
content = std::string{value};
set_dirty();
}
#ifdef RESET_TIMER
bool TextArea::on_key(const KeyEvent key) {
if (key == KeyEvent::Select && on_select) {
on_select(*this);
set_dirty();
return true;
}
return false;
}
#endif
// EPIRBAppView class
void EPIRBAppView::decode_packet(const baseband::Packet& packet, Beacon& beacon) {
// Convert packet bits to byte array for easier processing
uint8_t data[BEACON_DATA_SIZE]{};
for (size_t i = 0; i < std::min(packet.size() / 8, (size_t)BEACON_DATA_SIZE); i++) {
uint8_t byte_val = 0;
for (int bit = 0; bit < 8 && (i * 8 + bit) < packet.size(); bit++) {
if (packet[i * 8 + bit]) {
byte_val |= (1 << (7 - bit));
}
}
data[i] = byte_val;
}
beacon.setFrame(data);
// Set timestamp
rtc::RTC datetime;
rtcGetTime(&RTCD1, &datetime);
beacon.date = datetime;
}
#ifdef LOGGER
void EPIRBLogger::on_packet(Beacon& beacon) {
std::string entry = std::string(beacon.getType()) + "," +
beacon.hexId + "," +
beacon.getProtocolName(); // + ",";
// to_string_dec_uint(static_cast<uint8_t>(beacon.emergency_type)) + ",";
/*if (!beacon.location.isUnknown()) {
entry += beacon.location.toString(Location::LocationFormat::DECIMAL);
} else {
entry += ",";
}
entry += "," + beacon.country.toString() + "," +
beacon.getSatus() + "\n";*/
log_file.write_entry(beacon.date, entry);
}
#endif
// EPIRBDetailView class
EPIRBDetailView::EPIRBDetailView(
Rect parent_rect,
EPIRBAppView& parent)
: View(parent_rect), parent_app(parent) {
add_children({&text_beacon});
set_focusable(true);
}
#ifdef DETAIL_TAB_BEACON_SEL
bool EPIRBDetailView::on_encoder(EncoderEvent delta) {
// Change position in the list according to encoder
int32_t size = (int32_t)parent_app.beacon_db.size();
if (size == 0) return true;
// Get current index
int32_t current_index = (int32_t)parent_app.beacon_db.get_current_beacon_index();
// Compute new index with wrap around
int32_t new_index = (current_index + delta + size) % size;
// Set new index
parent_app.beacon_db.set_current_beacon(new_index % size);
parent_app.on_beacon_change();
return true;
}
#endif
// Append "CYAN<label>:WHITE <value>\t" — the most repeated pattern in set_beacon.
// Shared format string deduplicates ~5 unique formats; %s for label lets the
// per-call-site cost drop to a single function call.
static char* append_field(char* p, const char* label, const char* value) {
return p + sprintf(p, STR_COLOR_CYAN "%s:" STR_COLOR_WHITE " %s\t", label, value);
}
void EPIRBDetailView::set_beacon(Beacon& beacon) {
// We use a single TextArea widget to display beacon information for code size optimization
char buffer[400];
char* p = buffer;
bool isReal = (beacon.frameMode == Beacon::FrameMode::NORMAL);
p += sprintf(p, STR_COLOR_CYAN "Beacon:" STR_COLOR_WHITE " %s(%s%s" STR_COLOR_WHITE ") - ",
beacon.getType(),
isReal ? STR_COLOR_YELLOW : STR_COLOR_GREEN,
isReal ? "Real" : "Test");
p += beacon.formatTime(p);
*p++ = '\t';
p = append_field(p, "Protocol", beacon.getProtocolName());
p += sprintf(p, "%s\t", ResourceManager::get_beacon_resource((uint8_t)beacon.protocol));
if (beacon.hasAdditionalData) {
p += sprintf(p, "%s\t", beacon.additionalData);
}
if (beacon.hasEmergency) {
p += sprintf(p, STR_COLOR_CYAN "Emergency:" STR_COLOR_RED " %s\t", beacon.emergencyType);
}
p += sprintf(p, STR_COLOR_CYAN "Country:" STR_COLOR_WHITE " %s(%d) - %s\t" STR_COLOR_CYAN "Location:" STR_COLOR_WHITE " ",
beacon.country.alphaCode, beacon.country.code, beacon.country.shortName);
p += beacon.location.toString(p, Location::LocationFormat::MAIDENHEAD_LOCATOR, 8);
*p++ = '\t';
if (!beacon.location.isUnknown()) {
p += beacon.location.toString(p, Location::LocationFormat::SEXAGESIMAL);
*p++ = '\t';
p += beacon.location.toString(p, Location::LocationFormat::DECIMAL);
*p++ = '\t';
}
// BCH status: color depends on validity/correction, so the color stays a %s substitution.
p += sprintf(p, STR_COLOR_CYAN "Control: %s%s",
beacon.isBch1Valid() ? (beacon.bch1Corrected ? STR_COLOR_YELLOW : STR_COLOR_GREEN) : STR_COLOR_RED,
beacon.isBch1Valid() ? "BCH1-OK" : "BCH1-KO");
if (beacon.hasBch2) {
p += sprintf(p, " %s%s",
beacon.isBch2Valid() ? (beacon.bch2Corrected ? STR_COLOR_YELLOW : STR_COLOR_GREEN) : STR_COLOR_RED,
beacon.isBch2Valid() ? "BCH2-OK" : "BCH2-KO");
}
*p++ = '\t';
p = append_field(p, "Hex ID", beacon.hexId);
if (beacon.hasSerialNumber) {
p = append_field(p, "S/N", beacon.serialNumber);
}
if (beacon.hasMainLocatingDevice()) {
p = append_field(p, "Main loc. dev.", beacon.getMainLocatingDeviceName());
if (beacon.hasAuxLocatingDevice()) {
append_field(p, "Aux loc. dev.", beacon.getAuxLocatingDeviceName());
}
}
text_beacon.set_content(buffer);
}
// EPIRBMapView class
EPIRBMapView::EPIRBMapView(
Rect parent_rect)
: View(parent_rect) {
add_children({&geomap});
geomap.set_mode(DISPLAY);
geomap.set_manual_panning(false);
geomap.init();
geomap.set_focusable(true);
geomap.clear_markers();
geomap.move(lon_, lat_);
// Hide for now
geomap.hidden(true);
}
void EPIRBMapView::set_main_marker(const std::string& label, float lat, float lon) {
geomap.set_tag(label);
lat_ = lat;
lon_ = lon;
geomap.move(lon_, lat_);
}
void EPIRBMapView::clear_markers() {
geomap.clear_markers();
}
void EPIRBMapView::add_marker(GeoMarker& marker) {
geomap.store_marker(marker);
}
void EPIRBMapView::paint(Painter& painter) {
// Prevent view from clearing background if map is not hidden
if (map_hidden) {
View::paint(painter);
painter.draw_string({UI_POS_X_CENTER(7), UI_POS_MAXHEIGHT / 2 - (UI_POS_HEIGHT(1) / 2)}, *Theme::getInstance()->fg_light, "No data");
}
}
void EPIRBMapView::on_show() {
// Force redrawing map
repaint();
}
void EPIRBMapView::hide_map(bool hide) {
map_hidden = hide;
geomap.hidden(hide);
}
void EPIRBMapView::repaint() {
// Fake orientation change to force map redraw
geomap.update_my_orientation(180, false);
geomap.update_my_orientation(0, true);
set_dirty();
}
// EPRIBQRView class
EPRIBQRView::EPRIBQRView(Rect parent_rect)
: View(parent_rect) {
add_children({&text_data, &options_qr, &qr_code});
// Hide for now
qr_code.hidden(true);
options_qr.on_change = [this](size_t, ui::OptionsField::value_t v) {
show_map = (v == 0);
update_qr();
};
update_display();
}
void EPRIBQRView::set_beacon(Beacon* beacon) {
current_beacon = beacon;
update_qr();
update_display();
}
void EPRIBQRView::update_display() {
// Update data => we use a single TextArea component for code size optimizaton
char buffer[128];
char* buffer_pointer = buffer;
buffer_pointer += sprintf(buffer_pointer, "%sQR:%s\t\t\t\t\t\t\t\t", STR_COLOR_CYAN, STR_COLOR_WHITE);
if (current_beacon) {
buffer_pointer += sprintf(buffer_pointer, "%sData:%s\t", STR_COLOR_CYAN, STR_COLOR_WHITE);
// HEX ID 30 Hexa or HEX ID 22 Hexa bit 26 to 112
buffer_pointer += current_beacon->toHexString(buffer_pointer, current_beacon->frame, true, 3, 11);
(*(buffer_pointer++)) = '\t';
if (current_beacon->longFrame) {
current_beacon->toHexString(buffer_pointer, current_beacon->frame, true, 11, 18);
} else {
current_beacon->toHexString(buffer_pointer, current_beacon->frame, true, 11, 14);
}
}
text_data.set_content(buffer);
}
void EPRIBQRView::update_qr() {
// Update QR code
bool show_qr = false;
if (current_beacon) {
// We have a beacon
if (show_map) {
// Map is selected
if (!current_beacon->location.isUnknown()) {
// Loation is known => actually draw QR
current_beacon->location.formatFloatLocation(qr_url, MAPS_URL_TEMPLATE);
show_qr = true;
}
} else {
// Detail is selected
char* buffer_pointer = qr_url;
// Send to heroku decoder
buffer_pointer += sprintf(qr_url, BEACON_URL_TEMPALTE);
current_beacon->hexString(buffer_pointer, false);
show_qr = true;
}
}
if (show_qr) qr_code.set_text(qr_url);
qr_code.hidden(!show_qr);
set_dirty();
}
#ifdef SPECAN
EPIRBRxView::EPIRBRxView(
EPIRBAppView& parent,
Rect parent_rect)
: spectrum::WaterfallView(), app_view(parent) {
ui::Rect waterfall_rect{0, EPIRB_TAB_POS_Y, parent_rect.width(), parent_rect.height()};
spectrum::WaterfallView::set_parent_rect(waterfall_rect);
}
void EPIRBRxView::on_show() {
// Turn on spectrum
app_view.epirb_rx_config_message.spectrum_on = true;
app_view.send_config();
start();
}
void EPIRBRxView::on_hide() {
// Turn off spectrum
stop();
app_view.epirb_rx_config_message.spectrum_on = false;
app_view.send_config();
app_view.refresh();
}
#endif
// App View
EPIRBAppView::EPIRBAppView(ui::NavigationView& nav)
: nav_(nav) {
baseband::run_prepared_image(portapack::memory::map::m4_code.base());
add_children({&options_frequency,
&field_rf_amp,
&field_lna,
&field_vga,
&rssi,
//&audio,
#ifdef SQUELCH
&field_squelch,
#endif
&field_volume,
&channel,
&text_status,
&text_timeout,
&tab_view,
&view_list,
&view_detail,
&view_map,
#ifdef SPECAN
&view_rx,
#endif
&view_qr});
using option_t = std::pair<std::string, int32_t>;
using options_t = std::vector<option_t>;
options_t frequ_options;
// Set frequency combo content according to FREQ.TXT file content
for (auto freq : ResourceManager::get_frequencies()) {
int32_t freq_value = atol(freq.c_str());
frequ_options.emplace_back(to_string_rounded_freq(freq_value, 3), freq_value);
}
options_frequency.set_options(frequ_options);
options_frequency.on_change = [this](size_t, ui::OptionsField::value_t v) {
receiver_model.set_target_frequency(v);
};
// Restore frequency from preferencies
options_frequency.set_by_value(receiver_model.target_frequency());
// Tick second timer
signal_token_tick_second = rtc_time::signal_tick_second += [this]() {
this->on_tick_second();
};
#ifdef RESET_TIMER
text_timeout.set_focusable(true);
text_timeout.on_select = [this](TextArea&) {
timeout = ((countdown + 1) * -1);
on_tick_second();
};
#endif
tab_view.set_parent_rect(Rect(0, UI_POS_HEIGHT(4), screen_width, 3 * 8));
view_list.hidden(false);
view_detail.hidden(true);
view_map.hidden(true);
#ifdef SPECAN
view_rx.hidden(true);
#endif
view_qr.hidden(true);
view_list.set_db(beacon_db);
view_list.on_select = [this](size_t selected) {
beacon_db.set_current_beacon(selected);
on_beacon_change();
};
#ifdef SQUELCH
// Restore squelch value
field_squelch.set_value(squelch);
#endif
epirb_rx_config_message.squelch = squelch;
send_config();
#ifdef SQUELCH
field_squelch.on_change = [this](int32_t v) {
squelch = v;
epirb_rx_config_message.squelch = squelch;
send_config();
};
#endif
// Force sample rate to match baseband processor
receiver_model.set_sampling_rate(3072000);
receiver_model.enable();
audio::set_rate(audio::Rate::Hz_24000);
audio::output::start();
update_display();
#ifdef LOGGER
logger = std::make_unique<EPIRBLogger>();
if (logger) {
logger->append(logs_dir / "epirb_rx.txt");
}
#endif
}
EPIRBAppView::~EPIRBAppView() {
// Remove tick second timer
rtc_time::signal_tick_second -= signal_token_tick_second;
// Stop audio processor
audio::output::stop();
// Stop receiver
receiver_model.disable();
baseband::shutdown();
// Cleanup ResourceManager
ResourceManager::destroy();
}
void EPIRBAppView::refresh() {
// Force map repaint
view_map.repaint();
}
void EPIRBAppView::focus() {
options_frequency.focus();
}
void EPIRBAppView::on_packet(Message* const p) {
const auto message = static_cast<const EPIRBPacketMessage*>(p);
const baseband::Packet& packet = message->packet;
// Decode the EPIRB packet
if (packet.size() > 64) {
// Actual beacon
Beacon& beacon = beacon_db.add_beacon();
// Get a new beacon from database and load it
decode_packet(packet, beacon);
beacons_received++;
// Track packet statistics
if (beacon.isFrameValid()) {
if (beacon.bch1Corrected || beacon.bch2Corrected) {
packets_corrected++;
} else {
packets_valid++;
}
} else {
packets_error++;
}
// Update timeout
timeout = (countdown * -1);
// Reset selection
beacon_db.set_current_beacon(0);
// Update display
on_beacon_change();
#ifdef LOGGER
// Log the beacon
if (logger) {
logger->on_packet(beacon);
}
#endif
view_list.set_dirty();
}
}
void EPIRBAppView::on_beacon_change() {
// Beacon selection has changed => get new selection
Beacon& cur_beacon = beacon_db.get_current_beacon();
// Update detail, qr and map tab
view_detail.set_beacon(cur_beacon);
view_qr.set_beacon(&cur_beacon);
update_map();
// Update display
update_display();
}
void EPIRBAppView::update_map() {
// Clear previously saved markers
view_map.clear_markers();
bool hide_map = true;
size_t size = beacon_db.size();
if (size > 0) {
// Check if current beacon has a valid location
Beacon& beacon = beacon_db.get_current_beacon();
size_t cur_index = beacon_db.get_current_beacon_index();
if (!beacon.location.isUnknown()) {
hide_map = false;
// Set new position
view_map.set_main_marker(std::string(beacon.getType()) + "-" + beacon.shortId(), beacon.location.latitude.getFloatValue(), beacon.location.longitude.getFloatValue());
// Add all beacons with valid locations as markers
for (size_t j = 0; j < size; j++) {
if (cur_index != j) {
Beacon& other_beacon = beacon_db.get_beacon(j);
if (!other_beacon.location.isUnknown()) {
ui::GeoMarker marker;
marker.lat = other_beacon.location.latitude.getFloatValue();
marker.lon = other_beacon.location.longitude.getFloatValue();
marker.angle = 0;
marker.tag = std::string(other_beacon.getType()) + "-" + other_beacon.shortId();
view_map.add_marker(marker);
}
}
}
}
}
view_map.hide_map(hide_map);
view_map.repaint();
}
void EPIRBAppView::on_tick_second() {
timeout++;
// Limit to 3 digits
if (timeout > 999) timeout = 0;
text_timeout.set_content(to_string_dec_uint(abs(timeout)));
}
void EPIRBAppView::update_display() {
// We use a single TextArea for code size optimization
char buffer[128];
char* buffer_pointer = buffer;
buffer_pointer += sprintf(buffer_pointer, "%sListening... Beacon:%s%2d/%d\t", STR_COLOR_CYAN, STR_COLOR_WHITE, (beacon_db.get_current_beacon_index() + 1), beacons_received);
buffer_pointer += sprintf(buffer_pointer, "%sStats: %s%03dOK %s%03dCOR %s%03dERR\t", STR_COLOR_CYAN, STR_COLOR_GREEN, packets_valid, STR_COLOR_YELLOW, packets_corrected, STR_COLOR_RED, packets_error);
buffer_pointer += sprintf(buffer_pointer, "%sCurrent:%s ", STR_COLOR_CYAN, STR_COLOR_WHITE);
if (!beacon_db.empty()) {
beacon_db.get_current_beacon().formatSummary(buffer_pointer, false);
}
text_status.set_content(buffer);
}
void EPIRBAppView::send_config() {
// Send config to baseband
baseband::set_epirb_rx_config(epirb_rx_config_message);
}
} // namespace ui::external_app::epirb_rx