diff --git a/firmware/application/baseband_api.cpp b/firmware/application/baseband_api.cpp index c8920b411..c3ae6475f 100644 --- a/firmware/application/baseband_api.cpp +++ b/firmware/application/baseband_api.cpp @@ -422,6 +422,10 @@ void set_rtty_config(RTTYDataMessage& message) { send_message(&message); } +void set_epirb_tx_config(EPIRBTXDataMessage& message) { + send_message(&message); +} + static bool baseband_image_running = false; void run_image(const spi_flash::image_tag_t image_tag) { diff --git a/firmware/application/baseband_api.hpp b/firmware/application/baseband_api.hpp index ff4b8250d..49c4f9c27 100644 --- a/firmware/application/baseband_api.hpp +++ b/firmware/application/baseband_api.hpp @@ -116,6 +116,7 @@ void set_flex_config(); void set_bitstream_config(uint32_t deviation, uint8_t mode); // mode 0 for am, 1 for 2fsk void set_rtty_config(uint16_t baud, uint16_t shift, uint8_t* payload = nullptr, uint16_t payload_length = 0); // baud*100 void set_rtty_config(RTTYDataMessage& message); +void set_epirb_tx_config(EPIRBTXDataMessage& message); void request_roger_beep(); void request_rssi_beep(); diff --git a/firmware/application/external/epirb_tx/beacon.hpp b/firmware/application/external/epirb_tx/beacon.hpp new file mode 100644 index 000000000..c6918b488 --- /dev/null +++ b/firmware/application/external/epirb_tx/beacon.hpp @@ -0,0 +1,356 @@ +/* + * 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. + */ +#ifndef __BEACON_H__ +#define __BEACON_H__ + +#include "portapack.hpp" +#include "ui_epirb_tx.hpp" +#include "location.hpp" + +namespace ui::external_app::epirb_tx { + +/** + * Get bits frop the provided frame + * @param data the frame data + * @param startBit the start bit number (one based to match documentation) + * @param endBit the end bit number (one based to match documentation) + * @return the selected bits (max 64) + */ +static uint64_t get_bits(uint8_t* data, int startBit, int endBit) { + uint64_t result = 0; + // 0 bases bit count + startBit--; + int numBits = endBit - startBit; + + // get a pointer to the starting byte... + const uint8_t* pData = &(data[startBit / 8]); + uint8_t b = *pData; + + // calculate the starting bit within that byte... + int bitOffset = 7 - (startBit % 8); + + // iterate for the desired number of bits... + for (int i = 0; i < numBits; ++i) { + // make room for the next bit... + result <<= 1; + // copy the bit... + result |= ((b >> bitOffset) & 0x01); + // reached the end of the current byte? + if (--bitOffset < 0) { + b = *(++pData); // go to the next byte... + bitOffset = 7; // restart at the first bit in that byte... + } + } + + // all done... + return result; +} + +/** + * Compute a BCH code + * @param frame the frame data + * @param startBit the bit to start BCH calculation + * @param endBit the bit to stop calculation + * @param poly the BCH polynome + * @param polyLength the BCH polynome length + * @return the BCH code + */ +static uint64_t compute_bch(uint8_t* frame, int startBit, int endBit, unsigned long poly, int polyLength) { // Length of data to be checked (not including the BCH code) + int dataLength = endBit - startBit + 1; + // Total lengh (including the BCH code that will be padded to zeros (BCH code length is polyLengh-1)) + int totalLength = dataLength + polyLength - 1; + // Start with the first polyLength bits + uint64_t result = get_bits(frame, startBit, startBit + polyLength - 1); + for (int i = polyLength; i <= totalLength; i++) { // Iterate on each bit after the first polyLength batch + bool firstBit = result >> (polyLength - 1); + if (firstBit) { // We have a leading 1 => xor the result with the poly + result = result ^ poly; + } + if (i < totalLength) { // Move to next bit + result = result << 1; + if (i < dataLength) { // Append next bit + result |= get_bits(frame, startBit + i, startBit + i); + } // else : 0 padding after data length + } + } + return result; +} + +// 21 bits BCH polynomial +#define BCH_21_POLYNOMIAL 0b1001101101100111100011UL +#define BCH_21_POLY_LENGTH 22 +// 12 bits BCH polynomial +#define BCH_12_POLYNOMIAL 0b1010100111001UL +#define BCH_12_POLY_LENGTH 13 + +/** + * Combyte BCH1 code for the provided frame + */ +static uint64_t compute_bch1(uint8_t* frame) { + return compute_bch(frame, 25, 85, BCH_21_POLYNOMIAL, BCH_21_POLY_LENGTH); +} + +/** + * Combyte BCH2 code for the provided frame + */ +static uint64_t compute_bch2(uint8_t* frame) { + return compute_bch(frame, 107, 132, BCH_12_POLYNOMIAL, BCH_12_POLY_LENGTH); +} + +/** + * Set a bit in the provided frame + * @param buf the frame + * @param bit the position of the bit to set (0 based) + * @param v the bit value + */ +static void set_bit(uint8_t* buf, int bit, bool v) { + int byte = bit >> 3; + int off = 7 - (bit & 7); + + if (v) + buf[byte] |= (1 << off); + else + buf[byte] &= ~(1 << off); +} + +/** + * Push bits to the provided frame + * @param buf the frame + * @param pos the current frame possition (in/out, will be incremented during the opreration) + * @param v the bits to push (max 64) + * @param n the number of bits to push + */ +static void push_bits(uint8_t* buf, int& pos, uint64_t v, int n) { + for (int i = n - 1; i >= 0; i--) + set_bit(buf, pos++, (v >> i) & 1); +} + +/** + * Convert a beacon to hex string representation + * @param frame the frame to convert + * @param start true for the first half of the frame, false for the second half + * @return the hex string representation of the specieid half of the frame + */ +std::string beacon_to_hex_string(const uint8_t* frame, bool start) { + static const char hex[] = "0123456789ABCDEF"; + + std::string out; + out.resize(18); + + int offset = start ? 0 : 9; + + for (int i = 0; i < 9; i++) { + uint8_t b = frame[offset + i]; + + out[i * 2] = hex[b >> 4]; + out[i * 2 + 1] = hex[b & 0x0F]; + } + + return out; +} + +/** + * Generate a beacon in the provided buffer + * @param frame the buffer to generate the frame in + * @param params the beacon parameters + * @return the size of the generated frame + */ +size_t generate_beacon(uint8_t* frame, const BeaconParams& params) { + // Clear the content of the frame + memset(frame, 0, 18); + + int pos = 0; + uint32_t deg, min, sec; + + // bit sync + for (int i = 0; i < 15; i++) + push_bits(frame, pos, 1, 1); + + // frame sync + push_bits(frame, pos, params.is_test ? 0b011010000 : 0b000101111, 9); + + // PDF-1 (Protexted Data field 1) + int pdf1_start = pos; + + push_bits(frame, pos, 1, 1); // format flag (long) + bool is_user = (params.protocol == BeaconProtocol::USER); + bool is_standard = (params.protocol == BeaconProtocol::STANDARD); + push_bits(frame, pos, is_user, 1); // protocol flag + push_bits(frame, pos, params.country, 10); // country code + switch (params.type) { + case BeaconType::EPIRB: + if (is_user) + push_bits(frame, pos, 0b010, 3); + else if (is_standard) + push_bits(frame, pos, 0b0010, 4); + else + push_bits(frame, pos, 0b1010, 4); + break; + case BeaconType::PLB: + if (is_user) + push_bits(frame, pos, 0b011, 3); + else if (is_standard) + push_bits(frame, pos, 0b0111, 4); + else + push_bits(frame, pos, 0b1011, 4); + break; + default: + case BeaconType::ELT: + if (is_user) + push_bits(frame, pos, 0b001, 3); + else if (is_standard) + push_bits(frame, pos, 0b0011, 4); + else + push_bits(frame, pos, 0b1000, 4); + break; + } + + // Fill the rest of PDF 1 with zeros + while (pos < pdf1_start + 61) + push_bits(frame, pos, 0, 1); + + if (is_user) { + // User Location Protocol: no position in PDF1 + if (params.type == BeaconType::PLB) { + // Set bits for PLB + set_bit(frame, 40 - 1, 1); + set_bit(frame, 41 - 1, 1); + set_bit(frame, 42 - 1, 0); + } + + set_bit(frame, 85 - 1, params.has_121_5); + } else if (is_standard) { + // Standard Location Protocol + // North / south + set_bit(frame, 65 - 1, params.location.south); + // E / W + set_bit(frame, 75 - 1, params.location.west); + pos = 66 - 1; + // Latitude 1/4 degrees (9 bits) + deg = (params.location.lat_deg << 2) + (params.location.lat_min / 15); + push_bits(frame, pos, deg, 9); + pos = 76 - 1; + // Longitude 1/4 degrees (10 bits) + deg = (params.location.long_deg << 2) + (params.location.long_min / 15); + push_bits(frame, pos, deg, 10); + pos = pdf1_start + 61; + } else { + // National Location Protocol + // North / south + set_bit(frame, 59 - 1, params.location.south); + // E / W + set_bit(frame, 72 - 1, params.location.west); + pos = 60 - 1; + // Latitude degrees (7 bits) + push_bits(frame, pos, params.location.lat_deg, 7); + // Latitude min (5 bits, 2 min increment) + min = params.location.lat_min / 2; + push_bits(frame, pos, min, 5); + pos = 73 - 1; + // Longitude degrees (8 bits) + push_bits(frame, pos, params.location.long_deg, 8); + // Longiitude min (5 bits, 2 min increment) + min = params.location.long_min / 2; + push_bits(frame, pos, min, 5); + pos = pdf1_start + 61; + } + + // Set BCH1 + uint64_t bch1 = compute_bch1(frame); + push_bits(frame, pos, bch1, 21); + + // PDF-2 (Protexted Data Field 2) + int pdf2_start = pos; + if (is_user) { + // User Location Protocol + push_bits(frame, pos, params.is_internal, 1); + // Latitude N/S + push_bits(frame, pos, params.location.south, 1); + // Latitude degrees (7 bits) + push_bits(frame, pos, params.location.lat_deg, 7); + // Latitude minutes (4 bits, 4 minutes precision) + min = params.location.lat_min / 4; + push_bits(frame, pos, min, 4); + // Longitude E/W + push_bits(frame, pos, params.location.west, 1); + // Longitude degrees (8 bits) + push_bits(frame, pos, params.location.long_deg, 8); + // Longitude minutes (4 bits, 4 minutes precision) + min = params.location.long_min / 4; + push_bits(frame, pos, min, 4); + } else if (is_standard) { + // Standard Location Protocol + push_bits(frame, pos, 0b1101, 4); + push_bits(frame, pos, params.is_internal, 1); + push_bits(frame, pos, params.has_121_5, 1); + // Latiitude + // + + push_bits(frame, pos, 1, 1); + // Min + min = params.location.lat_min % 15; + push_bits(frame, pos, min, 5); + // Sec (4 bits, 4 sec precision) + sec = params.location.lat_sec / 4; + push_bits(frame, pos, sec, 4); + // Longitude + // + + push_bits(frame, pos, 1, 1); + // Min + min = params.location.long_min % 15; + push_bits(frame, pos, min, 5); + // Sec (4 bits, 4 sec precision) + sec = params.location.long_sec / 4; + push_bits(frame, pos, sec, 4); + } else { + // National Location Protocol + push_bits(frame, pos, 0b1101, 4); + push_bits(frame, pos, params.is_internal, 1); + push_bits(frame, pos, params.has_121_5, 1); + // Lat + // + + push_bits(frame, pos, 1, 1); + // Min + push_bits(frame, pos, (params.location.lat_min % 2), 2); + // Sec (4 bits, 4 sec precision) + sec = params.location.lat_sec / 4; + push_bits(frame, pos, sec, 4); + // LLon + // + + push_bits(frame, pos, 1, 1); + // Min + push_bits(frame, pos, (params.location.long_min % 2), 2); + // Sec (4 bits, 4 sec precision) + sec = params.location.long_sec / 4; + push_bits(frame, pos, sec, 4); + } + + while (pos < pdf2_start + 26) + push_bits(frame, pos, 0, 1); + + // Compute BCH 2 + uint64_t bch2 = compute_bch2(frame); + push_bits(frame, pos, bch2, 12); + return 18; +} + +} // namespace ui::external_app::epirb_tx + +#endif /*__BEACON_H__*/ \ No newline at end of file diff --git a/firmware/application/external/epirb_tx/location.hpp b/firmware/application/external/epirb_tx/location.hpp new file mode 100644 index 000000000..70d76f045 --- /dev/null +++ b/firmware/application/external/epirb_tx/location.hpp @@ -0,0 +1,268 @@ +/* + * 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. + */ +#ifndef __LOCATION_H__ +#define __LOCATION_H__ + +#include "portapack.hpp" +#include "ui_epirb_tx.hpp" +#include +#include +#include + +namespace ui::external_app::epirb_tx { + +/** + * Convert decimal coordinates to sexagesimal coordinates + * @param value the decimal value + * @param negative output N(false)/S(true) or E(false)/W(true) value + * @param deg output degrees value + * @param min output minutes value + * @param sec output seconds value + */ +static void decimal_to_dms(double value, bool& negative, uint16_t& deg, uint8_t& min, uint8_t& sec) { + negative = value < 0; + value = std::fabs(value); + + deg = (uint16_t)value; + + double m = (value - deg) * 60.0; + min = (uint8_t)m; + + double s = (m - min) * 60.0; + sec = (uint8_t)(s + 0.5); + + if (sec == 60) { + sec = 0; + min++; + } + + if (min == 60) { + min = 0; + deg++; + } +} + +/** + * Convert sexagesimal coordinates to decimal + * @param negative N(false)/S(true) or E(false)/W(true) value + * @param deg degrees value + * @param min minutes value + * @param sec seconds value + * @return value the decimal value + */ +static double dms_to_decimal(bool negative, uint16_t deg, uint8_t min, uint8_t sec) { + double v = deg + min / 60.0 + sec / 3600.0; + return negative ? -v : v; +} + +/** + * Convert maidenhead locator to decima coordinates + * @param loc the locator + * @param lat output latitude + * @param lon output longitude + */ +static void maidenhead_to_decimal(const std::string& loc, float& lat, float& lon) { + static const double lon_step[] = + { + 20.0, + 2.0, + 1.0 / 12.0, + 1.0 / 120.0, + 1.0 / 2880.0}; + + static const double lat_step[] = + { + 10.0, + 1.0, + 1.0 / 24.0, + 1.0 / 240.0, + 1.0 / 5760.0}; + + lon = -180.0; + lat = -90.0; + + int pairs = loc.size() / 2; + if (pairs > 5) + pairs = 5; + + for (int i = 0; i < pairs; i++) { + char c1 = std::toupper(loc[i * 2]); + char c2 = std::toupper(loc[i * 2 + 1]); + + double lon_size = lon_step[i]; + double lat_size = lat_step[i]; + + int v1, v2; + + if (i % 2 == 0) // letters + { + v1 = c1 - 'A'; + v2 = c2 - 'A'; + } else // digits + { + v1 = c1 - '0'; + v2 = c2 - '0'; + } + + lon += v1 * lon_size; + lat += v2 * lat_size; + } + + // Cell center + lon += lon_step[pairs - 1] / 2.0; + lat += lat_step[pairs - 1] / 2.0; +} + +/** + * Convert decimal coordinates to maidenhead locator + * @param lat the latitude + * @param lon the longitude + * @param precision (optional, defaults to 8) the number of charater for the maidenhead locator + * @return the locator + */ +static std::string decimal_to_maidenhead(double lat, double lon, int precision = 8) { + lon += 180.0; + lat += 90.0; + + int A = lon / 20; + int B = lat / 10; + + lon -= A * 20; + lat -= B * 10; + + int C = lon / 2; + int D = lat / 1; + + lon -= C * 2; + lat -= D * 1; + + int E = lon / (5.0 / 60.0); + int F = lat / (2.5 / 60.0); + + lon -= E * (5.0 / 60.0); + lat -= F * (2.5 / 60.0); + + int G = lon / (5.0 / 600.0); + int H = lat / (2.5 / 600.0); + + std::string locator; + + locator += char('A' + A); + locator += char('A' + B); + + if (precision >= 4) { + locator += char('0' + C); + locator += char('0' + D); + } + + if (precision >= 6) { + locator += char('a' + E); + locator += char('a' + F); + } + + if (precision >= 8) { + locator += char('0' + G); + locator += char('0' + H); + } + + return locator; +} + +/** + * Init the provided Location values from its locator + */ +void init_from_locator(Location& loc) { + maidenhead_to_decimal(loc.locator, loc.latitude, loc.longitude); + + decimal_to_dms(loc.latitude, + loc.south, + loc.lat_deg, + loc.lat_min, + loc.lat_sec); + + decimal_to_dms(loc.longitude, + loc.west, + loc.long_deg, + loc.long_min, + loc.long_sec); +} + +/** + * Init the provided Location values from its decimal coordinates + */ +void init_from_decimal(Location& loc) { + decimal_to_dms(loc.latitude, + loc.south, + loc.lat_deg, + loc.lat_min, + loc.lat_sec); + + decimal_to_dms(loc.longitude, + loc.west, + loc.long_deg, + loc.long_min, + loc.long_sec); + + loc.locator = decimal_to_maidenhead(loc.latitude, loc.longitude); +} + +/** + * Init the provided Location values from its sexagesimal coordinates + */ +void init_from_dms(Location& loc) { + loc.latitude = dms_to_decimal( + loc.south, + loc.lat_deg, + loc.lat_min, + loc.lat_sec); + + loc.longitude = dms_to_decimal( + loc.west, + loc.long_deg, + loc.long_min, + loc.long_sec); + + loc.locator = decimal_to_maidenhead(loc.latitude, loc.longitude); +} + +/** + * Convert the provided Loaction to it's latitude string (format ) + */ +std::string to_latitude_string(const Location& location) { + char buffer[16]; + // Format : + snprintf(buffer, sizeof(buffer), "%3d\260%02d'%02d\"%c", location.lat_deg, location.lat_min, location.lat_sec, location.south ? 'S' : 'N'); + return std::string(buffer); +} + +/** + * Convert the provided Loaction to it's longitude string (format ) + */ +std::string to_longitude_string(const Location& location) { + char buffer[16]; + // Format : + snprintf(buffer, sizeof(buffer), "%3d\260%02d'%02d\"%c", location.long_deg, location.long_min, location.long_sec, location.west ? 'W' : 'E'); + return std::string(buffer); +} + +} // namespace ui::external_app::epirb_tx + +#endif /*__LOCATION_H__*/ \ No newline at end of file diff --git a/firmware/application/external/epirb_tx/main.cpp b/firmware/application/external/epirb_tx/main.cpp new file mode 100644 index 000000000..3765b739a --- /dev/null +++ b/firmware/application/external/epirb_tx/main.cpp @@ -0,0 +1,82 @@ +/* + * 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 "ui.hpp" +#include "ui_epirb_tx.hpp" +#include "ui_navigation.hpp" +#include "external_app.hpp" + +namespace ui::external_app::epirb_tx { +void initialize_app(ui::NavigationView& nav) { + nav.push(); +} +} // namespace ui::external_app::epirb_tx +extern "C" { + +__attribute__((section(".external_app.app_epirb_tx.application_information"), used)) application_information_t _application_information_epirb_tx = { + /*.memory_location = */ (uint8_t*)0x00000000, + /*.externalAppEntry = */ ui::external_app::epirb_tx::initialize_app, + /*.header_version = */ CURRENT_HEADER_VERSION, + /*.app_version = */ VERSION_MD5, + + /*.app_name = */ "EPIRB TX", + /*.bitmap_data = */ { + 0x00, + 0x00, + 0x00, + 0x00, + 0x7C, + 0x3E, + 0xFE, + 0x7F, + 0xFF, + 0xFF, + 0xF7, + 0xEF, + 0xE3, + 0xC7, + 0xE3, + 0xC7, + 0xE3, + 0xC7, + 0xE3, + 0xC7, + 0xF7, + 0xEF, + 0xFF, + 0xFF, + 0xFE, + 0x7F, + 0x7C, + 0x3E, + 0x00, + 0x00, + 0x00, + 0x00, + }, + /*.icon_color = */ ui::Color::green().v, + /*.menu_location = */ app_location_t::TX, + /*.desired_menu_position = */ -1, + + /*.m4_app_tag = portapack::spi_flash::image_tag_epirb_tx */ {'P', 'E', 'P', 'T'}, + /*.m4_app_offset = */ 0x00000000, // will be filled at compile time +}; +} diff --git a/firmware/application/external/epirb_tx/ui_epirb_tx.cpp b/firmware/application/external/epirb_tx/ui_epirb_tx.cpp new file mode 100644 index 000000000..fdc1a3c19 --- /dev/null +++ b/firmware/application/external/epirb_tx/ui_epirb_tx.cpp @@ -0,0 +1,479 @@ +/* + * 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 "ui_epirb_tx.hpp" + +#include "tonesets.hpp" +#include "portapack.hpp" +#include "baseband_api.hpp" +#include "string_format.hpp" +#include "file_reader.hpp" +#include "file_path.hpp" +#include "ui_geomap.hpp" +#include "ui_alphanum.hpp" + +#include +#include + +#include "beacon.hpp" + +using namespace portapack; + +namespace ui::external_app::epirb_tx { + +void EPIRBTXAppView::focus() { + button_tx.focus(); +} + +EPIRBTXAppView::~EPIRBTXAppView() { + // Restore original frequency before leaving + transmitter_model.set_target_frequency(original_frequency); + transmitter_model.disable(); + baseband::shutdown(); +} + +/** + * Conversion from hex char to half byte + */ +static uint8_t hexval(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + return 0; +} + +/** + * Convert from hex chars to uint8_t + */ +static uint8_t hexToByte(char high, char low) { + return (hexval(high) << 4) | hexval(low); +} + +std::string EPIRBTXAppView::frame_to_hex_string(bool start) { + return beacon_to_hex_string(epirb_tx_message.data, start); +} + +void EPIRBTXAppView::generate_frame(BeaconParams params) { + epirb_tx_message.data_len = generate_beacon(epirb_tx_message.data, params); +} + +void EPIRBTXAppView::on_timer() { + // Timer is called on display refresh + if (loop) { + if (loop_enabled) { + // chTimeNow() returns milliseconds on our version of ChibiOS / Hardware + auto now = chTimeNow(); + auto elapsed = ((now - last_frame_time) / 1000); + std::string timeout = std::to_string((uint32_t)(delay > elapsed ? delay - elapsed : 0)); + if (timeout != text_timeout.get()) { + // Update timeout text every seconds + text_timeout.set(timeout); + } + if (now > (last_frame_time + (delay * 1000))) { + // Send a new frame after the delay + start_tx(); + } + } else { + // Stop send loop + loop = false; + } + } +} + +void EPIRBTXAppView::update_frame(bool updateConfig) { + if (mode_file) { + // In file mode, currently selected beacon has changed => load the new one + Beacon& beacon = beacons[selected_beacon]; + // Set desciption + text_description.set(beacon.description.substr(0, max_text_width_ext)); + text_description_end.set(beacon.description.size() > max_text_width_ext ? "-" + beacon.description.substr(max_text_width_ext, max_text_width_ext + max_text_width_ext - 1) : ""); + // Udapte frame content on display + text_frame.set(beacon.frame.substr(0, 18)); + text_frame_end.set(beacon.frame.size() > 18 ? beacon.frame.substr(18, 36) : ""); + // Prepare tx configuration + epirb_tx_message.data_len = std::min((beacon.frame.size() / 2), 18); + for (uint8_t i = 0; i < epirb_tx_message.data_len; i++) { + epirb_tx_message.data[i] = hexToByte( + beacon.frame[2 * i], + beacon.frame[2 * i + 1]); + } + } else { + // In manual mode, generate frame content for current beacon params + generate_frame(beacon_params); + // Update frame content on display + text_frame.set(frame_to_hex_string(true)); + text_frame_end.set(frame_to_hex_string(false)); + } + if (updateConfig && send_on_change && loop) { + // Need to update config / send new beacon + if (am_enabled) { + // Already transmitting => update config + last_frame_time = chTimeNow(); + update_config(); + } else { + // Not yet transmitting => start tx + start_tx(); + } + } +} + +void EPIRBTXAppView::update_config() { + if (epirb_tx_message.mode_bpsk) { + // Already in BPSK mode => backup bpsk frequency + bpsk_frequency = transmitter_model.target_frequency(); + } else { + // Previously in AM mode => restore bpsk frequency + transmitter_model.set_target_frequency(bpsk_frequency); + } + // Set mode to bpsk + epirb_tx_message.mode_bpsk = true; + // Set pre/post count + epirb_tx_message.pre_count = (150 * TONES_SAMPLERATE) / 1000; // 150 ms + epirb_tx_message.post_count = (100 * TONES_SAMPLERATE) / 1000; // 100 ms + // Send config to baseband + baseband::set_epirb_tx_config(epirb_tx_message); +} + +void EPIRBTXAppView::set_tx_button_state(bool active) { + button_tx.set_text(active ? "START" : "STOP"); + button_tx.set_style(active ? &style_tx_start : &style_tx_stop); +} + +void EPIRBTXAppView::start_tx() { + last_frame_time = chTimeNow(); + update_config(); + loop = loop_enabled; + transmitter_model.enable(); + tx_view.set_transmitting(true); + set_tx_button_state(false); + transmitting = true; +} + +void EPIRBTXAppView::stop_tx() { + loop = false; + transmitter_model.disable(); + tx_view.set_transmitting(false); + set_tx_button_state(true); + transmitting = false; +} + +void EPIRBTXAppView::on_tx_progress(const uint32_t progress, const bool done) { + (void)progress; + if (done) { + if (am_enabled) { + // BPSK frame sent, switch back to 121.5 AM signal + epirb_tx_message.mode_bpsk = false; + // Backup bpsk frequency for next run + bpsk_frequency = transmitter_model.target_frequency(); + // Restore am frequency + transmitter_model.set_target_frequency(am_frequency); + // Send config to baseband + baseband::set_epirb_tx_config(epirb_tx_message); + } else { + // End of BPSK frame + transmitter_model.disable(); + tx_view.set_transmitting(false); + if (!loop) { + // Not looping => update transmission state + set_tx_button_state(true); + transmitting = false; + } + } + } +} + +void EPIRBTXAppView::update_location(bool updateLocatorField) { + locator = beacon_params.location.locator; + if (updateLocatorField) text_field_beacon_locator.set_text(beacon_params.location.locator); + text_beacon_latitude_value.set(to_latitude_string(beacon_params.location)); + text_beacon_longitude_value.set(to_longitude_string(beacon_params.location)); +} + +void EPIRBTXAppView::update_mode() { + // Hide / show widgets for file mode or manual mode + text_beacon.hidden(!mode_file); + text_description_label.hidden(!mode_file); + options_frame.hidden(!mode_file); + text_description.hidden(!mode_file); + text_description_end.hidden(!mode_file); + text_beacon_type.hidden(mode_file); + text_beacon_country.hidden(mode_file); + checkbox_beacon_internal.hidden(mode_file); + text_beacon_locator.hidden(mode_file); + text_beacon_latitude.hidden(mode_file); + text_beacon_latitude_value.hidden(mode_file); + text_beacon_longitude.hidden(mode_file); + text_beacon_longitude_value.hidden(mode_file); + button_mangps.hidden(mode_file); + options_beacon_type.hidden(mode_file); + options_beacon_protocol.hidden(mode_file); + options_beacon_country.hidden(mode_file); + text_field_beacon_locator.hidden(mode_file); +} + +EPIRBTXAppView::EPIRBTXAppView( + NavigationView& nav) { + baseband::run_prepared_image(portapack::memory::map::m4_code.base()); + + add_children({&labels, + &options_mode, + &text_beacon, + &text_description_label, + &text_beacon_type, + &options_beacon_type, + &options_beacon_protocol, + &text_beacon_country, + &options_beacon_country, + &checkbox_beacon_internal, + &text_beacon_locator, + &text_beacon_latitude, + &text_beacon_latitude_value, + &text_beacon_longitude, + &text_beacon_longitude_value, + &button_mangps, + &text_field_beacon_locator, + &options_frame, + &text_description, + &text_description_end, + &text_frame, + &text_frame_end, + &text_timeout, + &checkbox_loop, + &field_delay, + &button_tx, + &checkbox_am, + &field_am_frequency, + &checkbox_send_on_change, + &tx_view}); + + text_beacon.set_style(Theme::getInstance()->fg_light); + text_description_label.set_style(Theme::getInstance()->fg_light); + text_beacon_type.set_style(Theme::getInstance()->fg_light); + text_beacon_country.set_style(Theme::getInstance()->fg_light); + text_beacon_locator.set_style(Theme::getInstance()->fg_light); + text_beacon_latitude.set_style(Theme::getInstance()->fg_light); + text_beacon_longitude.set_style(Theme::getInstance()->fg_light); + + // Restore settings + checkbox_am.set_value(am_enabled); + checkbox_loop.set_value(loop_enabled); + checkbox_send_on_change.set_value(send_on_change); + options_mode.set_by_value(!mode_file); + transmitter_model.set_target_frequency(bpsk_frequency); + field_am_frequency.set_value(am_frequency); + field_delay.set_value(delay); + options_beacon_type.set_by_value(beacon_type); + options_beacon_protocol.set_by_value(beacon_protocol); + options_beacon_country.set_by_value(beacon_country); + checkbox_beacon_internal.set_value(beacon_internal); + beacon_params.type = (BeaconType)beacon_type; + beacon_params.has_121_5 = am_enabled; + beacon_params.location.locator = locator; + beacon_params.is_internal = beacon_internal; + init_from_locator(beacon_params.location); + update_mode(); + update_location(); + + options_mode.on_change = [this](size_t index, OptionsField::value_t) { + mode_file = (index == 0); + update_mode(); + update_frame(); + set_dirty(); + }; + + options_beacon_type.on_change = [this](size_t index, OptionsField::value_t) { + beacon_params.type = (BeaconType)index; + beacon_type = index; + update_frame(); + set_dirty(); + }; + + options_beacon_protocol.on_change = [this](size_t index, OptionsField::value_t) { + beacon_params.protocol = (BeaconProtocol)index; + beacon_protocol = index; + update_frame(); + set_dirty(); + }; + + options_beacon_country.on_change = [this](size_t, OptionsField::value_t v) { + beacon_params.country = v; + beacon_country = v; + update_frame(); + set_dirty(); + }; + + checkbox_beacon_internal.on_select = [this](Checkbox&, bool v) { + beacon_internal = v; + beacon_params.is_internal = v; + update_frame(); + set_dirty(); + }; + + text_field_beacon_locator.on_select = [this, &nav](TextField&) mutable { + auto te_view = nav.push(locator, 10, ENTER_KEYBOARD_MODE_ALPHA); + te_view->on_changed = [this](std::string& value) { + beacon_params.location.locator = value; + init_from_locator(beacon_params.location); + update_location(); + update_frame(); + set_dirty(); + }; + }; + + button_mangps.on_select = [this, &nav](Button&) { + nav.push( + 0, + GeoPos::alt_unit::METERS, + GeoPos::spd_unit::HIDDEN, + beacon_params.location.latitude, + beacon_params.location.longitude, + [this](int32_t, float lat, float lon, int32_t) { + beacon_params.location.latitude = lat; + beacon_params.location.longitude = lon; + init_from_decimal(beacon_params.location); + // Update locator field + update_location(); + update_frame(); + set_dirty(); + }); + }; + + field_am_frequency.on_change = [this](rf::Frequency freq) { + am_frequency = freq; + if (transmitting && !epirb_tx_message.mode_bpsk && am_enabled) + // Update transmitter frequency + transmitter_model.set_target_frequency(am_frequency); + }; + + // Load available beacons from BEACONS.TXT files (or default). + load_beacons(); + // Setup options_frame content with loaded beacons + using option_t = std::pair; + using options_t = std::vector; + options_t entries; + for (const auto& beacon : beacons) + entries.emplace_back(beacon.title, entries.size()); + + options_frame.set_options(std::move(entries)); + if (selected_beacon >= beacons.size()) + // BEACONS.TXT file has changed since last launch, default index to 0 + selected_beacon = 0; + options_frame.set_selected_index(selected_beacon); + options_frame.on_change = [this](size_t index, OptionsField::value_t) { + selected_beacon = index; + update_frame(); + set_dirty(); + }; + + // Init frame content / baseband param with currently setup beacon + update_frame(false); + + checkbox_loop.on_select = [this](Checkbox&, bool v) { + loop_enabled = v; + }; + + field_delay.on_change = [this](int32_t v) { + delay = v; + }; + + checkbox_am.on_select = [this](Checkbox&, bool v) { + beacon_params.has_121_5 = v; + am_enabled = v; + if (!mode_file) update_frame(false); + }; + + // AM frequency field edit + field_am_frequency.on_edit = [this, &nav]() { + auto new_view = nav.push(field_am_frequency.value()); + new_view->on_changed = [this](rf::Frequency f) { + field_am_frequency.set_value(f); + }; + }; + + checkbox_send_on_change.on_select = [this](Checkbox&, bool v) { + send_on_change = v; + }; + + tx_view.on_edit_frequency = [this, &nav]() { + auto new_view = nav.push(transmitter_model.target_frequency()); + new_view->on_changed = [this](rf::Frequency f) { + transmitter_model.set_target_frequency(f); + bpsk_frequency = f; + }; + }; + + tx_view.on_start = [this]() { + start_tx(); + }; + + tx_view.on_stop = [this]() { + stop_tx(); + }; + + button_tx.on_select = [this](Button&) { + if (!transmitting) + start_tx(); + else + stop_tx(); + }; +} + +/** + * Load beacons from /EPIRB/BEACONS.TXT file on sd card + */ +void EPIRBTXAppView::load_beacons() { + File beacons_file; + auto error = beacons_file.open(epirb_dir / u"BEACONS.TXT"); + beacons.clear(); + + if (!error) { + // BEACONS.TXT file exists + auto reader = FileLineReader(beacons_file); + for (const auto& line : reader) { + // Skip comment lines + if (line.length() == 0 || line[0] == '#') + continue; + + // Split line with ';' separator + auto cols = split_string(line, ';'); + if (cols.size() != 3) + continue; + + // Read current beacon + Beacon beacon{}; + beacon.title = trim(cols[0]); + beacon.description = trim(cols[1]); + // Make sure frame is not longer tha 18 bytes / 36 hex character + beacon.frame = trim(cols[2]).substr(0, 36); + size_t size = beacon.frame.size(); + if (size <= 0) + continue; // Invalid line. + // Beacon is valid, add it to the list + beacons.emplace_back(std::move(beacon)); + } + } + if (beacons.empty()) { + // No beacons file or empty flile: just add default beacon + beacons.push_back(default_beacon); + } +} + +} // namespace ui::external_app::epirb_tx \ No newline at end of file diff --git a/firmware/application/external/epirb_tx/ui_epirb_tx.hpp b/firmware/application/external/epirb_tx/ui_epirb_tx.hpp new file mode 100644 index 000000000..132e933d7 --- /dev/null +++ b/firmware/application/external/epirb_tx/ui_epirb_tx.hpp @@ -0,0 +1,332 @@ +/* + * 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. + */ + +#ifndef __EPIRB_TX_H__ +#define __EPIRB_TX_H__ + +#include "app_settings.hpp" +#include "radio_state.hpp" +#include "ui.hpp" +#include "ui_widget.hpp" +#include "ui_navigation.hpp" +#include "ui_transmitter.hpp" + +#include "portapack.hpp" +#include "message.hpp" +#include "tonesets.hpp" + +#define BEACON_HEXA_SIZE 36 +#define BEACON_HEXA_HALF_SIZE 18 +#define BEACON_SIZE 18 + +namespace ui::external_app::epirb_tx { + +enum class BeaconType { + EPIRB = 0, + ELT = 1, + PLB = 2 +}; + +enum class BeaconProtocol { + USER = 0, + STANDARD = 1, + NATIONAL = 2 +}; + +struct Location { + std::string locator; + bool south; + uint16_t lat_deg; + uint8_t lat_min; + uint8_t lat_sec; + float latitude; + bool west; + uint16_t long_deg; + uint8_t long_min; + uint8_t long_sec; + float longitude; +}; + +struct BeaconParams { + BeaconType type; + BeaconProtocol protocol; + uint32_t country; + bool is_test; + bool is_internal; + bool has_121_5; + Location location; +}; + +class EPIRBTXAppView : public View { + public: + EPIRBTXAppView(NavigationView& nav); + ~EPIRBTXAppView(); + + void focus() override; + + std::string title() const override { return "EPIRB TX"; }; + + private: + void start_tx(); + void stop_tx(); + void update_config(); + void on_tx_progress(const uint32_t progress, const bool done); + void on_timer(); + void load_beacons(); + void set_tx_button_state(bool active); + std::string frame_to_hex_string(bool start); + void generate_frame(BeaconParams params); + void update_frame(bool updateConfig = true); + void update_mode(); + void update_location(bool updateLocatorField = true); + + struct Beacon { + std::string title{}; + std::string description{}; + std::string frame{}; + }; + std::vector beacons{}; + Beacon default_beacon{"Self test", "Serial User Location Protocol", "FFFED0D6E6202820000C29FF51041775302D"}; + + BeaconParams beacon_params{BeaconType::ELT, BeaconProtocol::STANDARD, 227, true, true, true, {"JN03RO", false, 0, 0, 0, 0, false, 0, 0, 0, 0}}; + + // Currently selected beacon index (from BEACONS.TXT file / options_frame combo) + uint32_t selected_beacon{0}; + + // Frequency of the transmitter before starting the app (used to restore frequency when leaving) + rf::Frequency original_frequency{0}; + // Frequency of the AM emergency signal + rf::Frequency am_frequency{121500000}; + // Frequency of the 406 MHz BPSK signal + rf::Frequency bpsk_frequency{406025000}; + + // True when using a beacon from the BEACONS.TXT file + bool mode_file{true}; + // True when looping on sending beacons is enabled + bool loop_enabled{true}; + // True if AM emergency signal transmission is enabled + bool am_enabled{true}; + // True if we want to send a new frame each time the user changes the current beacon + bool send_on_change{false}; + // The current locator string + std::string locator{"JN03RO"}; + // The delay between each frame when on loop mode + uint32_t delay{50}; + uint8_t beacon_type{0}; + // Currently selected beacon protocol + uint8_t beacon_protocol{0}; + // Currently selected beacon country + uint32_t beacon_country{227}; + // Current beacon's internal state (true for internal location system) + bool beacon_internal{true}; + + TxRadioState radio_state_{ + 0 /* frequency */, + 1750000 /* bandwidth */, + TONES_SAMPLERATE /* sampling rate */ + }; + app_settings::SettingsManager settings_{ + "tx_epirb", + app_settings::Mode::TX, + { + {"sbeacon"sv, &selected_beacon}, + {"amfreq"sv, &am_frequency}, + {"bpskfreq"sv, &bpsk_frequency}, + {"loop"sv, &loop_enabled}, + {"delay"sv, &delay}, + {"file"sv, &mode_file}, + {"am"sv, &am_enabled}, + {"soc"sv, &send_on_change}, + {"type"sv, &beacon_type}, + {"proto"sv, &beacon_protocol}, + {"country"sv, &beacon_country}, + {"internal"sv, &beacon_internal}, + {"locator"sv, &locator}, + }}; + + // Time of the last sent frame + uint32_t last_frame_time{0}; + // True when transmission is enabled + bool transmitting{false}; + // True when currently looping on sending beacons + bool loop{false}; + + // Current EPIRBTXDataMessage for baseband + EPIRBTXDataMessage epirb_tx_message{}; + + const size_t max_text_width = UI_POS_WIDTH_REMAINING(6) / UI_POS_DEFAULT_WIDTH; + const size_t max_text_width_ext = UI_POS_WIDTH_REMAINING(0) / UI_POS_DEFAULT_WIDTH; + + Labels labels{ + {{UI_POS_X(0), UI_POS_Y(0)}, "Source:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), UI_POS_Y(6)}, "Frame:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), UI_POS_Y(10)}, "Next frame in s.", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), UI_POS_Y(12)}, "AM frequency MHz", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(17), UI_POS_Y(9)}, "s.", Theme::getInstance()->fg_light->foreground}}; + + // For file mode + Text text_beacon{ + {UI_POS_X(0), UI_POS_Y(1), UI_POS_WIDTH(7), UI_POS_DEFAULT_HEIGHT}, + "Beacon:"}; + // Beacon selection from BEACONS.TXT + OptionsField options_frame{ + {UI_POS_X(7), UI_POS_Y(1)}, + 30, + {}}; + Text text_description_label{ + {UI_POS_X(0), UI_POS_Y(2), UI_POS_WIDTH(12), UI_POS_DEFAULT_HEIGHT}, + "Description:"}; + Text text_description{ + {UI_POS_X(0), UI_POS_Y(3), UI_POS_WIDTH_REMAINING(0), UI_POS_DEFAULT_HEIGHT}, + ""}; + Text text_description_end{ + {UI_POS_X(0), UI_POS_Y(4), UI_POS_WIDTH_REMAINING(0), UI_POS_DEFAULT_HEIGHT}, + ""}; + + // For manual mode + Text text_beacon_type{ + {UI_POS_X(0), UI_POS_Y(1), UI_POS_WIDTH(8), UI_POS_DEFAULT_HEIGHT}, + "Type:"}; + Text text_beacon_country{ + {UI_POS_X(0), UI_POS_Y(2), UI_POS_WIDTH(8), UI_POS_DEFAULT_HEIGHT}, + "Country:"}; + Checkbox checkbox_beacon_internal{ + {UI_POS_X_RIGHT(12), UI_POS_Y(2)}, + 12, + "Internal", + true}; + Text text_beacon_locator{ + {UI_POS_X(0), UI_POS_Y(3), UI_POS_WIDTH(8), UI_POS_DEFAULT_HEIGHT}, + "Locator:"}; + Text text_beacon_latitude{ + {UI_POS_X(0), UI_POS_Y(4), UI_POS_WIDTH(8), UI_POS_DEFAULT_HEIGHT}, + "Lat.:"}; + Text text_beacon_longitude{ + {UI_POS_X(0), UI_POS_Y(5), UI_POS_WIDTH(8), UI_POS_DEFAULT_HEIGHT}, + "Long.:"}; + Text text_beacon_latitude_value{ + {UI_POS_X(7), UI_POS_Y(4), UI_POS_WIDTH(12), UI_POS_DEFAULT_HEIGHT}, + ""}; + Text text_beacon_longitude_value{ + {UI_POS_X(7), UI_POS_Y(5), UI_POS_WIDTH(12), UI_POS_DEFAULT_HEIGHT}, + ""}; + OptionsField options_beacon_type{ + {UI_POS_X(9), UI_POS_Y(1)}, + 7, + {{"EPIRB", 0}, + {"ELT", 1}, + {"PLB", 2}}}; + OptionsField options_beacon_protocol{ + {UI_POS_X(9 + 7), UI_POS_Y(1)}, + 30, + {{"User", 0}, + {"Standard", 1}, + {"National", 2}}}; + OptionsField options_beacon_country{ + {UI_POS_X(9), UI_POS_Y(2)}, + 7, + {{"France", 227}, + {"USA", 366}, + {"Germany", 211}, + {"Russia", 273}, + {"Spain", 224}, + {"Japan", 431}, + {"UK", 232}}}; + TextField text_field_beacon_locator{ + {UI_POS_X(9), UI_POS_Y(3), UI_POS_WIDTH(10), UI_POS_DEFAULT_HEIGHT}, + "JN03RO"}; + Button button_mangps{ + {UI_POS_X_RIGHT(9), UI_POS_Y(3), UI_POS_WIDTH(9), UI_POS_HEIGHT(2)}, + "Set pos."}; + + // Mode selection (file/manual) + OptionsField options_mode{ + {UI_POS_X(7), UI_POS_Y(0)}, + 30, + {{"File (BEACONS.TXT)", 0}, + {"Manual (Editor)", 1}}}; + + // Frame content + Text text_frame{ + {UI_POS_X(6), UI_POS_Y(6), UI_POS_WIDTH_REMAINING(6), UI_POS_DEFAULT_HEIGHT}, + ""}; + Text text_frame_end{ + {UI_POS_X(6), UI_POS_Y(7), UI_POS_WIDTH_REMAINING(6), UI_POS_DEFAULT_HEIGHT}, + ""}; + + Text text_timeout{ + {UI_POS_X(14), UI_POS_Y(10), UI_POS_WIDTH(2), UI_POS_DEFAULT_HEIGHT}, + ""}; + + // Transmission settings + Checkbox checkbox_loop{ + {UI_POS_X(0), UI_POS_Y(9)}, + 10, + "Resend every", + true}; + NumberField field_delay{ + {UI_POS_X(15), UI_POS_Y(9)}, + 2, + {1, 99}, + 1, + ' '}; + Checkbox checkbox_am{ + {UI_POS_X(0), UI_POS_Y(11)}, + 10, + "AM signal", + true}; + FrequencyField field_am_frequency{ + {UI_POS_X(13), UI_POS_Y(12)}}; + Checkbox checkbox_send_on_change{ + {UI_POS_X(0), UI_POS_Y(13)}, + 14, + "Send on change", + true}; + Button button_tx{ + {UI_POS_X_RIGHT(9), UI_POS_Y(9), UI_POS_WIDTH(9), UI_POS_HEIGHT(2)}, + "START"}; + const Style& style_tx_start = *Theme::getInstance()->fg_green; + const Style& style_tx_stop = *Theme::getInstance()->fg_red; + + // Transmitter view + TransmitterView tx_view{ + (int16_t)UI_POS_Y_BOTTOM(4), + 10000, + 12}; + + MessageHandlerRegistration message_handler_tx_progress{ + Message::ID::TXProgress, + [this](const Message* const p) { + const auto message = *reinterpret_cast(p); + this->on_tx_progress(message.progress, message.done); + }}; + + MessageHandlerRegistration message_handler_frame_sync{ + // Use frame sync for our applcation timer callback + Message::ID::DisplayFrameSync, + [this](const Message* const) { + this->on_timer(); + }}; +}; + +} // namespace ui::external_app::epirb_tx + +#endif /*__EPIRB_TX_H__*/ diff --git a/firmware/application/external/external.cmake b/firmware/application/external/external.cmake index d3d66b2a3..a17f9df32 100644 --- a/firmware/application/external/external.cmake +++ b/firmware/application/external/external.cmake @@ -260,6 +260,10 @@ set(EXTCPPSRC external/epirb_rx/main.cpp external/epirb_rx/ui_epirb_rx.cpp + #epirb_tx + external/epirb_tx/main.cpp + external/epirb_tx/ui_epirb_tx.cpp + #soundboard 272byte - 1236 bytes external/soundboard/main.cpp external/soundboard/soundboard_app.cpp @@ -389,6 +393,7 @@ set(EXTAPPLIST battleship ert epirb_rx + epirb_tx soundboard game2048 bht_tx diff --git a/firmware/application/external/external.ld b/firmware/application/external/external.ld index d2cf96846..feb000e58 100644 --- a/firmware/application/external/external.ld +++ b/firmware/application/external/external.ld @@ -102,6 +102,7 @@ MEMORY ram_external_app_time_sink (rwx) : org = 0xADFD0000, len = 32k 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 } @@ -582,5 +583,10 @@ SECTIONS *(*ui*external_app*kiss_tnc*); } > ram_external_app_kiss_tnc + .external_app_epirb_tx : ALIGN(4) SUBALIGN(4) + { + KEEP(*(.external_app.app_epirb_tx.application_information)); + *(*ui*external_app*epirb_tx*); + } > ram_external_app_epirb_tx } diff --git a/firmware/application/file_path.cpp b/firmware/application/file_path.cpp index 942052bd6..95e504957 100644 --- a/firmware/application/file_path.cpp +++ b/firmware/application/file_path.cpp @@ -57,3 +57,4 @@ const std::filesystem::path macaddress_dir = u"MACADDRESS"; const std::filesystem::path splash_dot_bmp = u"/splash.bmp"; const std::filesystem::path keeloq_keys_dir = u"KEELOQKEYS"; const std::filesystem::path keeloq_remotes_dir = u"KEELOQREMOTES"; +const std::filesystem::path epirb_dir = u"EPIRB"; diff --git a/firmware/application/file_path.hpp b/firmware/application/file_path.hpp index bfe0e6d0b..af244c6eb 100644 --- a/firmware/application/file_path.hpp +++ b/firmware/application/file_path.hpp @@ -58,6 +58,7 @@ extern const std::filesystem::path waterfalls_dir; extern const std::filesystem::path macaddress_dir; extern const std::filesystem::path keeloq_keys_dir; extern const std::filesystem::path keeloq_remotes_dir; +extern const std::filesystem::path epirb_dir; extern const std::filesystem::path splash_dot_bmp; diff --git a/firmware/baseband/CMakeLists.txt b/firmware/baseband/CMakeLists.txt index 1cf0fd95d..4ea0c4e69 100644 --- a/firmware/baseband/CMakeLists.txt +++ b/firmware/baseband/CMakeLists.txt @@ -535,6 +535,14 @@ set(MODE_CPPSRC ) DeclareTargets(PEPI epirb_rx) +### EPIRB TX + +set(MODE_CPPSRC + proc_epirb_tx.cpp +) +DeclareTargets(PEPT epirb_tx) + + ### NRF RX diff --git a/firmware/baseband/proc_epirb_tx.cpp b/firmware/baseband/proc_epirb_tx.cpp new file mode 100644 index 000000000..90a603a2b --- /dev/null +++ b/firmware/baseband/proc_epirb_tx.cpp @@ -0,0 +1,191 @@ +/* + * 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 "proc_epirb_tx.hpp" +#include "portapack_shared_memory.hpp" +#include "sine_table_int8.hpp" +#include "event_m4.hpp" + +#include +#include + +/** + * Processing method for this processor + */ +void EPIRBTXProcessor::execute(const buffer_c8_t& buffer) { + if (!configured) return; + + // Iterate on each sample of the buffer + for (size_t i = 0; i < buffer.count; i++) { + if (end_of_transmission) { + // Stop transmission + configured = false; + end_of_transmission = false; + txprogress_message.done = true; + shared_memory.application_queue.push(txprogress_message); + } + + if (mode_bpsk) { + // BPSK Manchester beacon signal + if (bpsk_pre_count < config_pre_count) { + // Pre-count state: send a negative phase carrier during pre-count + bpsk_pre_count++; + re = i_neg; + im = q_neg; + } else if (bpsk_post_count > 0) { + // Post-count: send a negative phase carrier during post-count + bpsk_post_count++; + re = i_neg; + im = q_neg; + if (bpsk_post_count >= config_post_count) { + // End transmission here + byte_index = 0; + bpsk_post_count = 0; + bpsk_pre_count = 0; + end_of_transmission = true; + } + } else { + if (sample_counter == 0 && manchester_half == false) { + if (bit_index == 0) { + // Read current byte + current_byte = frame_data[byte_index]; + // Move to next byte + byte_index++; + } + // Get current bit + current_bit = (current_byte >> (7 - bit_index)) & 0x01; + } + + // Manchester encoding + if (current_bit == 1) { + // 1 = falling signal + if (manchester_half == false) { + re = i_pos; + im = q_pos; + } else { + re = i_neg; + im = q_neg; + } + } else { + // 0 = rising signal + if (manchester_half == false) { + re = i_neg; + im = q_neg; + } else { + re = i_pos; + im = q_pos; + } + } + // Move to next sample + sample_counter++; + + if (sample_counter >= samples_per_halfbit) { + // Move to next half-bit + sample_counter = 0; + manchester_half = !manchester_half; + + // Next bit after two half bits + if (manchester_half == false) { + // Move to next bit + bit_index++; + if (bit_index >= 8) { + // End of byte + bit_index = 0; + if (byte_index >= frame_data_len) { + // End of frame => move to post-count + bpsk_post_count = 1; + } + } + } + } + } + } else { + // AM 127.5 MHz sine sweep + // ---- 3 Hz Sweep ---- + sweep_phase += sweep_inc; + uint8_t sweep_index = (sweep_phase & 0xFF000000) >> 24; + int8_t sweep = sine_table_i8[sweep_index]; // -128..127 + + // Audio frequency based on sweep + int32_t audio_freq = center_freq + sweep * freq_dev; + + // ---- Audio signal (sine wave) ---- + uint32_t audio_inc = audio_freq * freq_scale; + audio_phase += audio_inc; + + uint8_t audio_index = (audio_phase & 0xFF000000) >> 24; + int8_t audio = sine_table_i8[audio_index]; + + // ---- AM ---- + // Double Side Band modulation with modulation index of ~80% (100/128) + offset (74) + int16_t amplitude = 74 + ((100 * audio) >> 7); // 1/128 via shift + + if (amplitude > 127) amplitude = 127; + if (amplitude < -128) amplitude = -128; + + re = (int8_t)amplitude; + im = 0; + } + buffer.p[i] = {re, im}; + } +}; + +void EPIRBTXProcessor::on_message(const Message* const msg) { + // Configure the processor + switch (msg->id) { + case Message::ID::EPIRBTXData: { + const auto message = *reinterpret_cast(msg); + // Check transmission mode + mode_bpsk = message.mode_bpsk; + if (mode_bpsk) { + // BPSK mode for 406 frame + config_pre_count = message.pre_count; + config_post_count = message.post_count; + frame_data_len = message.data_len; + // Get the frame data from the message + memcpy(frame_data, message.data, std::min(frame_data_len, EPIRBTXDataMessage::max_len)); + // Init BPSK sequencer + sample_counter = 0; + bpsk_pre_count = 0; + bpsk_post_count = 0; + bit_index = 0; + byte_index = 0; + current_byte = 0; + current_bit = 0; + } else { + // AM mode for 121.5 signal => init AM sequencer + sweep_phase = 0; + audio_phase = 0; + } + // Tell the processor to start + configured = true; + } break; + + default: + break; + } +} + +int main() { + EventDispatcher event_dispatcher{std::make_unique()}; + event_dispatcher.run(); + return 0; +} diff --git a/firmware/baseband/proc_epirb_tx.hpp b/firmware/baseband/proc_epirb_tx.hpp new file mode 100644 index 000000000..1b624b56e --- /dev/null +++ b/firmware/baseband/proc_epirb_tx.hpp @@ -0,0 +1,111 @@ +/* + * 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. + */ + +#ifndef __PROC_EPIRB_TX_H__ +#define __PROC_EPIRB_TX_H__ + +#include "baseband_processor.hpp" +#include "baseband_thread.hpp" +#include "portapack_shared_memory.hpp" +#include "tonesets.hpp" +#include + +/** + * Processor used by epirb_tx app to simulate a COSPAS/SARSAT emergency beacon + * The processor will alternatively: + * - Send a 406 MHz Manchester encoded BPSK signal containing the beacon information + * - Send a 127.5 MHz AM distress audio signal + */ +class EPIRBTXProcessor : public BasebandProcessor { + public: + void execute(const buffer_c8_t& buffer) override; + void on_message(const Message* const msg) override; + + private: + // True when the processor has received a configuration message from the app + bool configured{false}; + // True when in BPSK transmission mode, false for AM transmission mode + bool mode_bpsk{false}; + + // True when the transmission has to be stopped (e.g. at the end of a frame) + bool end_of_transmission{}; + // I/Q values for current sample (ranging from -128 to 127) + int8_t re{0}, im{0}; + + // Configured pre-count value: used to generate a carrier for config_pre_count samples before starting a frame + uint32_t config_pre_count = 0; + // Configured post-count value: used to continue the carrier for config_post_count samples after the end of a frame + uint32_t config_post_count = 0; + + // Data of the frame to send in BPSK mode + uint8_t frame_data[18]{0}; + // Size of the frame to send in BPSK mode + uint8_t frame_data_len = 0; + + // BPSK parameters: Target phase +/-63° as per COSPAS/SARSAT specifications + static constexpr float phase_rad = 63.0f * M_PI / 180.0f; + + // I/Q values for BPSK (positive phase and negative phase) + int8_t i_pos = (int8_t)(cos(phase_rad) * 127); + int8_t q_pos = (int8_t)(sin(phase_rad) * 127); + int8_t i_neg = i_pos; + int8_t q_neg = -q_pos; + + // COSPAS/SARSAT signal is manchester (2 states per bit) encoded 400 bit/sec + static const uint32_t samples_per_halfbit = TONES_SAMPLERATE / 400 / 2; + // Sequencer state for BPSK + uint32_t sample_counter = 0; + uint32_t bpsk_pre_count = 0; + uint32_t bpsk_post_count = 0; + // Bit position in current byte + uint32_t bit_index = 0; + // Byte position in current frame + uint32_t byte_index = 0; + // Value of the current byte + uint8_t current_byte = 0; + // Value of the current bit + uint8_t current_bit = 0; + // Position in the current manchester bit + bool manchester_half = false; // false = first half + + // 127.5 AM signal parameters + static const uint32_t sweep_rate = 3; // 3 Hz + static const uint32_t f_min = 300; // Sweep min frequency (Hz) + static const uint32_t f_max = 1600; // Sweep max frequency (Hz) + static const uint32_t freq_span = f_max - f_min; + // Frequency + static const uint32_t freq_scale = (1ULL << 32) / TONES_SAMPLERATE; + static const int32_t center_freq = f_min + (freq_span / 2); + static const int32_t freq_dev = freq_span / 256; + // Increments + static const uint32_t sweep_inc = sweep_rate * freq_scale; + + // Phase accumulators for sweep and audio + uint32_t sweep_phase = 0; + uint32_t audio_phase = 0; + + TXProgressMessage txprogress_message{}; + + /* NB: Threads should be the last members in the class definition. */ + BasebandThread baseband_thread{TONES_SAMPLERATE, this, baseband::Direction::Transmit}; +}; + +#endif diff --git a/firmware/common/message.hpp b/firmware/common/message.hpp index 34436faa3..d6340af6f 100644 --- a/firmware/common/message.hpp +++ b/firmware/common/message.hpp @@ -156,6 +156,7 @@ class Message { RTTYData = 98, NotificationData = 99, TimeSinkConfig = 100, + EPIRBTXData = 101, MAX }; @@ -1110,6 +1111,19 @@ class SigGenToneMessage : public Message { const uint32_t tone_delta; }; +class EPIRBTXDataMessage : public Message { + public: + static constexpr uint8_t max_len = 18; + constexpr EPIRBTXDataMessage() + : Message{ID::EPIRBTXData} { + } + bool mode_bpsk = true; + uint8_t data[max_len]{0}; + uint8_t data_len = 0; + uint32_t pre_count = 0; + uint32_t post_count = 0; +}; + class AFSKTxConfigureMessage : public Message { public: constexpr AFSKTxConfigureMessage( diff --git a/firmware/common/spi_image.hpp b/firmware/common/spi_image.hpp index c09809ac4..bab9d1099 100644 --- a/firmware/common/spi_image.hpp +++ b/firmware/common/spi_image.hpp @@ -88,6 +88,7 @@ constexpr image_tag_t image_tag_am_tv{'P', 'A', 'M', 'T'}; constexpr image_tag_t image_tag_capture{'P', 'C', 'A', 'P'}; constexpr image_tag_t image_tag_ert{'P', 'E', 'R', 'T'}; constexpr image_tag_t image_tag_epirb_rx{'P', 'E', 'P', 'I'}; +constexpr image_tag_t image_tag_epirb_tx{'P', 'E', 'P', 'T'}; constexpr image_tag_t image_tag_nfm_audio{'P', 'N', 'F', 'M'}; constexpr image_tag_t image_tag_pocsag{'P', 'P', 'O', 'C'}; constexpr image_tag_t image_tag_pocsag2{'P', 'P', 'O', '2'}; diff --git a/sdcard/EPIRB/BEACONS.TXT b/sdcard/EPIRB/BEACONS.TXT new file mode 100644 index 000000000..e5ba8406d --- /dev/null +++ b/sdcard/EPIRB/BEACONS.TXT @@ -0,0 +1,25 @@ +#Title; Description; Frame (Hex format : 18 Bytes / 36 Hex char) +Selftest; Serial user Location Protocol; FFFED0D6E6202820000C29FF51041775302D +Standard Test; ADRASEC02 30/11/2014 N49d16m32s/E3d16m32s; FFFED08e3e0425a8318074fe44b735cd7b46 +User protocol KO; Radio call sign (wrong BCH1); FFFED03EF613523F81FE0 +User protocol OK; Radio call sign (valid BCH1); FFFED056E6804002202009655250 +RLS Location; RLS Location; FFFED08E0D0990014710021963C85C7009F5 +PLB Location; PLB Location: National Location; FFFED08E3B15F1DFC0FF07FD1F769F3C0672 +RLS Location 2; RLS Location Protocol; FFFED08F4DCBC01ACD004BB10D4E79C4DD86 +Std Loc. ELT 24; ELT 24-bit Address Protocol; FFFED096E3AAAAAA7FDFF8211F3583E0FAA8 +Standard Location; Standard Location Protocol EPIRB (Serial); FFFED096E61B0CAE7FDFFF0E58B583E0FAA8 +PLB Location; PLB Location: National Location; FFFED09F7B00F9E8EC737E5312378A1802B0 +EPIRB; Standard Location Protocol EPIRB-MMSI; FFFED0A0D205F260850F3DC9E0B70B6E4FD7 +PLB; Standard Location Protocol - PLB (Serial); FFFED0A3E7B10016150D364D8B3689C09437 +SAAS; Std. Location ship security protocol (SSAS); FFFED0A5DC19E1A07FDFFE5C803483E0FCCA +ELT 24; Serial user Location (ELT with Aircraft 24-bit Address); FFFED0CE46E76EF8C00C2BAA31CFE0FF0F61 +MMSO; Maritime User Protocol MMSI - EPIRB; FFFED0D9D4EB28140AA6893F16A2C67282EC +Float Free EPIRB; Float Free EPIRB with Serial Identification Number; FFFED0DF76A9A9C800174DE27BE1F0277E45 +Non Float Free EPIRB; Non Float Free EPIRB with Serial Identification; FFFED0DF77200000001168C610AFE0FF0146 +Radio Call Sign; Radio Call Sign - EPIRB; FFFED0E0DDAE599508268E4C05E054651307 +ELT Aviation; ELT Aviation User; FFFED0E29325ACB12E938B671E0FE0FF0F61 +ELT Aircraft; ELT with Aircraft Operator Designator & Serial Number; FFFED0EDF67B7182038C2F0E10CFE0FF0F61 +PLB Serial; Standard Location Protocol - PLB (Serial); FFFED0A157B081437FDFF8B4833783E0F66C +RLS Location; RLS Location Protocol; FFFED096ED09900149D4D467EE0851A3B2E8 +Orbitography; Orbitography Protocol (from Toulouse FMCC's LUT); FFFE2FCE3000000000000DBD0E4022417500 +ELT 24 2; ELT 24 bits; FFFED08DB345B146202DDF3C71F59BAB7072 \ No newline at end of file