diff --git a/firmware/application/external/external.cmake b/firmware/application/external/external.cmake index c8dc21a2b..d2e45d861 100644 --- a/firmware/application/external/external.cmake +++ b/firmware/application/external/external.cmake @@ -325,6 +325,10 @@ set(EXTCPPSRC external/pocsag_tx/main.cpp external/pocsag_tx/ui_pocsag_tx.cpp + #flex_tx + external/flex_tx/main.cpp + external/flex_tx/ui_flex_tx.cpp + #time_sink external/time_sink/main.cpp external/time_sink/ui_time_sink.cpp @@ -430,6 +434,7 @@ set(EXTAPPLIST rtty_rx rtty_tx pocsag_tx + flex_tx time_sink kiss_tnc p25_tx diff --git a/firmware/application/external/external.ld b/firmware/application/external/external.ld index 0bd3c2b8c..958277397 100644 --- a/firmware/application/external/external.ld +++ b/firmware/application/external/external.ld @@ -108,6 +108,7 @@ MEMORY ram_external_app_p25_tx (rwx) : org = 0xAE030000, len = 32k ram_external_app_two_tone_pager (rwx) : org = 0xAE040000, len = 32k ram_external_app_two_tone_rx (rwx) : org = 0xAE050000, len = 32k + ram_external_app_flex_tx (rwx) : org = 0xAE060000, len = 32k } SECTIONS @@ -621,4 +622,10 @@ SECTIONS KEEP(*(.external_app.app_two_tone_rx.application_information)); *(*ui*external_app*two_tone_rx*); } > ram_external_app_two_tone_rx + + .external_app_flex_tx : ALIGN(4) SUBALIGN(4) + { + KEEP(*(.external_app.app_flex_tx.application_information)); + *(*ui*external_app*flex_tx*); + } > ram_external_app_flex_tx } diff --git a/firmware/application/external/flex_tx/main.cpp b/firmware/application/external/flex_tx/main.cpp new file mode 100644 index 000000000..2fc5a32e0 --- /dev/null +++ b/firmware/application/external/flex_tx/main.cpp @@ -0,0 +1,62 @@ +#include "ui.hpp" +#include "ui_flex_tx.hpp" +#include "ui_navigation.hpp" +#include "external_app.hpp" + +namespace ui::external_app::flex_tx { +void initialize_app(ui::NavigationView& nav) { + nav.push(); +} +} // namespace ui::external_app::flex_tx + +extern "C" { + +__attribute__((section(".external_app.app_flex_tx.application_information"), used)) application_information_t _application_information_flex_tx = { + /*.memory_location = */ (uint8_t*)0x00000000, + /*.externalAppEntry = */ ui::external_app::flex_tx::initialize_app, + /*.header_version = */ CURRENT_HEADER_VERSION, + /*.app_version = */ VERSION_MD5, + + /*.app_name = */ "FLEX TX", + /*.bitmap_data = */ { + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0xFC, + 0x3F, + 0xFE, + 0x7F, + 0x02, + 0x40, + 0xBA, + 0x45, + 0x02, + 0x40, + 0xFE, + 0x7F, + 0xFE, + 0x7F, + 0x92, + 0x7C, + 0x92, + 0x7C, + 0xFC, + 0x3F, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + }, + /*.icon_color = */ ui::Color::cyan().v, + /*.menu_location = */ app_location_t::TX, + /*.desired_menu_position = */ -1, + + /*.m4_app_tag = portapack::spi_flash::image_tag_fsktx */ {'P', 'F', 'S', 'K'}, + /*.m4_app_offset = */ 0x00000000, +}; +} diff --git a/firmware/application/external/flex_tx/ui_flex_tx.cpp b/firmware/application/external/flex_tx/ui_flex_tx.cpp new file mode 100644 index 000000000..2160bd763 --- /dev/null +++ b/firmware/application/external/flex_tx/ui_flex_tx.cpp @@ -0,0 +1,898 @@ +#include "ui_flex_tx.hpp" + +#include "baseband_api.hpp" +#include "string_format.hpp" +#include "ui_textentry.hpp" +#include "portapack_persistent_memory.hpp" +#include "portapack_shared_memory.hpp" +#include "rtc_time.hpp" + +#include + +using namespace portapack; + +namespace ui::external_app::flex_tx { + +#define MAX_FLEX_MSG 240 +#define BCH_POLY 0x769 +#define DATA_MASK 0x1FFFFF + +// ===== Bit reversal ===== + +static uint32_t reverse_bits32(uint32_t v) { + v = ((v >> 1) & 0x55555555) | ((v & 0x55555555) << 1); + v = ((v >> 2) & 0x33333333) | ((v & 0x33333333) << 2); + v = ((v >> 4) & 0x0F0F0F0F) | ((v & 0x0F0F0F0F) << 4); + v = ((v >> 8) & 0x00FF00FF) | ((v & 0x00FF00FF) << 8); + v = (v >> 16) | (v << 16); + return v; +} + +// ===== BCH(31,21) encoder ===== + +static uint32_t flex_encode_word(uint32_t dw) { + uint32_t data = dw >> 11; + uint32_t dividend = data << 10; + for (int i = 30; i >= 10; i--) { + if ((dividend >> i) & 1) + dividend ^= BCH_POLY << (i - 10); + } + uint32_t ecc = dividend & 0x3FF; + uint32_t code31 = (data << 10) | ecc; + uint32_t p = 0, tmp = code31; + while (tmp) { + p ^= (tmp & 1); + tmp >>= 1; + } + return (code31 << 1) | p; +} + +static uint32_t flex_enc(uint32_t data21) { + return flex_encode_word(reverse_bits32(data21)); +} + +// ===== Checksum ===== + +static uint32_t flex_checksum(uint32_t d) { + uint32_t s = (d & 0xF) + ((d >> 4) & 0xF) + ((d >> 8) & 0xF) + + ((d >> 12) & 0xF) + ((d >> 16) & 0xF) + ((d >> 20) & 0x1); + return (d & ~0xFU) | ((0xF - (s & 0xF)) & 0xF); +} + +// ===== Year equivalence ===== + +static int flex_is_leap(int y) { + return (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0); +} + +static int flex_jan1_dow(int y) { + static const int t[] = {0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4}; + return (y + y / 4 - y / 100 + y / 400 + t[0] + 1) % 7; +} + +static int flex_equiv_year(int year) { + if (year >= 1994 && year <= 2025) + return year; + int target_leap = flex_is_leap(year); + int target_dow = flex_jan1_dow(year); + for (int y = 2025; y >= 1994; y--) { + if (flex_is_leap(y) == target_leap && + flex_jan1_dow(y) == target_dow) + return y; + } + return 1994 + ((year - 1994) & 0x1F); +} + +// ===== FIW ===== + +static uint32_t flex_fiw(uint32_t cycle, uint32_t frame, uint32_t roaming) { + uint32_t d = 0; + d |= (cycle & 0xF) << 4; + d |= (frame & 0x7F) << 8; + d |= (roaming & 1) << 15; + // r=0, t=0 (single transmission, no low traffic flags) + return flex_enc(flex_checksum(d)); +} + +// ===== BIW1 ===== + +static uint32_t flex_biw1(uint32_t astart, uint32_t vstart, uint32_t collapse) { + uint32_t d = 0; + d |= ((astart - 1) & 0x03) << 8; + d |= (vstart & 0x3F) << 10; + d |= (collapse & 0x07) << 18; + return flex_enc(flex_checksum(d)); +} + +// ===== BIW Date (type 001) ===== + +static uint32_t flex_biw_date(uint32_t year_field, uint32_t month, uint32_t day) { + uint32_t d = 0; + d |= (1U) << 4; // type = 001 + d |= (year_field & 0x1F) << 7; + d |= (day & 0x1F) << 12; + d |= (month & 0x0F) << 17; + return flex_enc(flex_checksum(d)); +} + +// ===== BIW Time (type 010) ===== + +static uint32_t flex_biw_time(uint32_t hour, uint32_t minute, uint32_t second_step) { + uint32_t d = 0; + d |= (2U) << 4; // type = 010 + d |= (hour & 0x1F) << 7; + d |= (minute & 0x3F) << 12; + d |= (second_step & 0x07) << 18; + return flex_enc(flex_checksum(d)); +} + +// ===== BIW SysInfo (type 101) ===== + +static uint32_t flex_biw_sysinfo(uint32_t a_type, uint32_t info) { + uint32_t d = 0; + d |= (5U) << 4; // type = 101 + d |= (a_type & 0x0F) << 7; + d |= (info & 0x03FF) << 11; + return flex_enc(flex_checksum(d)); +} + +// ===== BIW SSID1 (type 000) ===== + +static uint32_t flex_biw_ssid1(uint32_t local_id, uint32_t coverage_zone) { + uint32_t d = 0; + // type = 000 (no bits to set) + d |= (coverage_zone & 0x1F) << 7; + d |= (local_id & 0x01FF) << 12; + return flex_enc(flex_checksum(d)); +} + +// ===== BIW SSID2 (type 111) ===== + +static uint32_t flex_biw_ssid2(uint32_t country_code, uint32_t tmf) { + uint32_t d = 0; + d |= (7U) << 4; // type = 111 + d |= (tmf & 0x0F) << 7; + d |= (country_code & 0x03FF) << 11; + return flex_enc(flex_checksum(d)); +} + +// ===== Short address ===== + +static uint32_t flex_short_addr(uint64_t capcode) { + return flex_enc((uint32_t)(capcode + 0x8000) & DATA_MASK); +} + +// ===== Long address (2 words) ===== + +static int flex_long_addr(uint64_t capcode, uint32_t out[2]) { + uint64_t result; + uint32_t w1, w2; + if (capcode >= 2101249ULL && capcode <= 1075843072ULL) { + result = capcode - 2068481ULL; + w1 = (result % 32768) + 1; + w2 = 2097151U - (result / 32768); + } else if (capcode >= 1075843073ULL && capcode <= 3223326720ULL) { + result = capcode - 2068481ULL; + w1 = (result % 32768) + 1; + w2 = (result / 32768) + 1933312U; + } else if (capcode >= 3223326721ULL && capcode <= 4297068542ULL) { + result = capcode - 2068479ULL; + w1 = (result % 32768) + 2064383U; + w2 = (result / 32768) + 1867776U; + } else { + return -1; + } + out[0] = flex_enc(w1 & DATA_MASK); + out[1] = flex_enc(w2 & DATA_MASK); + return 0; +} + +// ===== Vector words ===== + +static uint32_t flex_alpha_vector(uint32_t mw_start, uint32_t mw_count) { + uint32_t d = 0; + d |= (5U & 0x07) << 4; + d |= (mw_start & 0x7F) << 7; + d |= (mw_count & 0x7F) << 14; + return flex_enc(flex_checksum(d)); +} + +static uint32_t flex_numeric_vector(uint32_t type, uint32_t mw_start, uint32_t mw_count, uint32_t kbit) { + uint32_t n_field = (mw_count > 0) ? mw_count - 1 : 0; + uint32_t d = 0; + d |= (type & 0x07) << 4; + d |= (mw_start & 0x7F) << 7; + d |= (n_field & 0x07) << 14; + d |= (kbit & 0x0F) << 17; + return flex_enc(flex_checksum(d)); +} + +// Short vector (type 2, t=00): BCD digits in vector word +// Short addr: 3 digits in d0-d11. Long addr: 3 + 5 in 2nd word. +// Tone-only: call with empty string (all digits become space 0xC). +static uint32_t flex_short_vector(int is_long, const std::string& msg, uint32_t* vy_out) { + static const char bcd_chars[20] = "0123456789 U -]["; + auto to_bcd = [&](char c) -> uint8_t { + for (int k = 0; k < 16; k++) + if (bcd_chars[k] == c) return k; + return 0xC; // space + }; + + int max_digits = is_long ? 8 : 3; + uint8_t digits[8]; + for (int i = 0; i < 8; i++) { + digits[i] = (i < (int)msg.size() && i < max_digits) ? to_bcd(msg[i]) : 0xC; + } + + uint32_t vw = 0; + vw |= (2U & 0x07) << 4; // type = 010 + // t1t0 = 00 (numeric) — bits 7-8 stay 0 + vw |= ((uint32_t)digits[0] & 0xF) << 9; + vw |= ((uint32_t)digits[1] & 0xF) << 13; + vw |= ((uint32_t)digits[2] & 0xF) << 17; + + if (is_long && vy_out) { + uint32_t d2 = 0; + for (int i = 0; i < 5 && (i + 3) < max_digits; i++) + d2 |= ((uint32_t)digits[i + 3] & 0xF) << (i * 4); + *vy_out = flex_enc(d2); + } + return flex_enc(flex_checksum(vw)); +} + +// ===== Block interleave ===== + +static void flex_interleave_block(uint32_t blk, uint32_t* frame_words) { + uint32_t src[8]; + uint8_t dst[32]; + memcpy(src, frame_words + blk * 8, sizeof(src)); + for (uint32_t i = 0; i < 32; i++) { + dst[i] = (uint8_t)((((src[0] >> (31 - i)) & 1) << 7) | + (((src[1] >> (31 - i)) & 1) << 6) | + (((src[2] >> (31 - i)) & 1) << 5) | + (((src[3] >> (31 - i)) & 1) << 4) | + (((src[4] >> (31 - i)) & 1) << 3) | + (((src[5] >> (31 - i)) & 1) << 2) | + (((src[6] >> (31 - i)) & 1) << 1) | + (((src[7] >> (31 - i)) & 1) << 0)); + } + memcpy(frame_words + blk * 8, dst, sizeof(dst)); +} + +// ===== Alpha encoding ===== + +static int flex_encode_alpha(const std::string& msg, uint32_t* words, int max_words, uint32_t seq, uint32_t msg_r) { + int wc = 0; + uint32_t hdr = 0; + hdr |= (3U << 11); + hdr |= ((seq & 0x3F) << 13); + hdr |= ((msg_r & 1U) << 19); + words[wc++] = hdr; + + size_t ci = 0; + uint32_t dw = 0; + uint8_t ch; + ch = (ci < msg.size()) ? msg[ci++] : 0x03; + dw |= ((uint32_t)(ch & 0x7F)) << 7; + ch = (ci < msg.size()) ? msg[ci++] : 0x03; + dw |= ((uint32_t)(ch & 0x7F)) << 14; + words[wc++] = dw; + + while (ci < msg.size() && wc < max_words) { + dw = 0; + for (int s = 0; s < 3; s++) { + ch = (ci < msg.size()) ? msg[ci++] : 0x03; + dw |= ((uint32_t)(ch & 0x7F)) << (s * 7); + } + words[wc++] = dw; + } + + // Signature + { + uint32_t sig_sum = 0; + for (int i = 1; i < wc; i++) { + uint32_t c0 = (words[i] >> 0) & 0x7F; + uint32_t c1 = (words[i] >> 7) & 0x7F; + uint32_t c2 = (words[i] >> 14) & 0x7F; + if (c0 != 0x03) sig_sum += c0; + if (c1 != 0x03) sig_sum += c1; + if (c2 != 0x03) sig_sum += c2; + } + words[1] = (words[1] & ~0x7FU) | ((~sig_sum) & 0x7F); + } + + // K checksum + { + uint32_t k_sum = 0; + for (int i = 0; i < wc; i++) { + k_sum += words[i] & 0xFF; + k_sum += (words[i] >> 8) & 0xFF; + k_sum += (words[i] >> 16) & 0x1F; + } + words[0] |= ((~k_sum) & 0x3FF); + } + + for (int i = 0; i < wc; i++) + words[i] = flex_enc(words[i]); + + return wc; +} + +// ===== Numeric BCD encoding ===== + +static int flex_encode_numeric(const std::string& msg, uint32_t* words, int max_words, uint32_t* k_out) { + static const char bcd[20] = "0123456789 U -]["; + uint32_t mw[8] = {0}; + int bit = 2; + int word_idx = 0; + for (size_t i = 0; i < msg.size(); i++) { + uint8_t nib = 0; + for (int k = 0; k < 16; k++) + if (bcd[k] == msg[i]) { + nib = k; + break; + } + for (int b = 0; b < 4; b++) { + word_idx = bit / 21; + if (word_idx >= 8) break; + if (nib & (1 << b)) + mw[word_idx] |= (1U << (bit % 21)); + bit++; + } + } + word_idx = (bit > 0) ? (bit - 1) / 21 : 0; + { + int end_bit = (word_idx + 1) * 21; + while (bit + 4 <= end_bit) { + for (int b = 0; b < 4; b++) { + if (0x0C & (1 << b)) + mw[bit / 21] |= (1U << (bit % 21)); + bit++; + } + } + } + uint32_t kb = 0; + for (int i = 0; i <= word_idx; i++) { + kb += mw[i] & 0xFF; + kb += (mw[i] >> 8) & 0xFF; + kb += (mw[i] >> 16) & 0x1F; + } + kb &= 0xFF; + kb = (kb & 0x3F) + (kb >> 6); + kb = ~kb; + mw[0] |= ((kb >> 4) & 0x03); + *k_out = kb & 0x0F; + int wc = word_idx + 1; + for (int i = 0; i < wc && i < max_words; i++) + words[i] = flex_enc(mw[i]); + return wc; +} + +// ===== Sync patterns ===== + +static const uint8_t bs1[] = {0xAA, 0xAA, 0xAA, 0xAA}; +static const uint8_t a1[] = {0x78, 0xF3, 0x59, 0x39}; +static const uint8_t b_code[4] = {0x55, 0x55, 0x00, 0x00}; +static const uint8_t ar[] = {0xCB, 0x20, 0x59, 0x39}; + +static void write_word(uint8_t*& p, uint32_t w) { + *p++ = (w >> 24) & 0xFF; + *p++ = (w >> 16) & 0xFF; + *p++ = (w >> 8) & 0xFF; + *p++ = w & 0xFF; +} + +// ===== Build frame ===== + +static size_t build_frame(uint8_t* buf, uint64_t capcode, int msg_type, const std::string& msg, uint32_t cycle, uint32_t frame, uint32_t msg_r, const FlexBIWParams& bp) { + uint8_t* p = buf; + + // S1 sync + memcpy(p, bs1, 4); + p += 4; + memcpy(p, a1, 4); + p += 4; + memcpy(p, b_code, 2); + p += 2; + for (int i = 0; i < 4; i++) *p++ = ~a1[i]; + + // FIW + write_word(p, flex_fiw(cycle, frame, bp.roaming)); + + // S2 + { + uint8_t s2[5]; + memset(s2, 0, 5); + uint64_t bits = 0; + bits |= (uint64_t)0xA << 36; + bits |= (uint64_t)0xED84 << 20; + bits |= (uint64_t)0x5 << 16; + bits |= (uint64_t)0x127B; + s2[0] = (bits >> 32) & 0xFF; + s2[1] = (bits >> 24) & 0xFF; + s2[2] = (bits >> 16) & 0xFF; + s2[3] = (bits >> 8) & 0xFF; + s2[4] = bits & 0xFF; + memcpy(p, s2, 5); + p += 5; + } + + // Build extra BIW words (max 3 slots) + uint32_t extra_biw[4]; + int extra_count = 0; + + // Slot priority: SSID1 first (always when enabled), then Time, Date, TZ, SSID2 + if (bp.send_ssid1 && extra_count < 3) + extra_biw[extra_count++] = flex_biw_ssid1(bp.local_id, bp.coverage_zone); + if (bp.send_time && extra_count < 3) { + uint32_t sec_step = (uint32_t)(bp.second * 2 / 15); // 7.5s steps + if (sec_step > 7) sec_step = 7; + extra_biw[extra_count++] = flex_biw_time(bp.hour, bp.minute, sec_step); + } + if (bp.send_date && extra_count < 3) { + uint32_t year_field = (uint32_t)(bp.year - 1994); + extra_biw[extra_count++] = flex_biw_date(year_field, bp.month, bp.day); + } + if (bp.send_tz && extra_count < 3) { + uint32_t tz_info = (uint32_t)bp.tz_code & 0x1F; + if (bp.send_dst) + tz_info |= (0U << 5); // L0=0 means DST active + else + tz_info |= (1U << 5); // L0=1 means standard time + extra_biw[extra_count++] = flex_biw_sysinfo(0x04, tz_info); + } + if (bp.send_ssid2 && extra_count < 3) + extra_biw[extra_count++] = flex_biw_ssid2(bp.country_code, 0); + + uint32_t fw[88]; + uint32_t mw[84]; + int mwc = 0; + uint32_t num_k = 0; + + if (msg_type == 0) + mwc = flex_encode_alpha(msg, mw, 84, bp.msg_number, msg_r); + else if (msg_type == 1) + mwc = flex_encode_numeric(msg, mw, 84, &num_k); + + // Address encoding + int is_long = (capcode >= 2101249ULL); + int addr_words = is_long ? 2 : 1; + int vec_words = is_long ? 2 : 1; + int astart = 1 + extra_count; + int vstart = astart + addr_words; + int mstart = vstart + vec_words; + + fw[0] = flex_biw1(astart, vstart, 0); + + // Extra BIW words (words 1..extra_count) + for (int i = 0; i < extra_count; i++) + fw[1 + i] = extra_biw[i]; + + // Address words + if (is_long) { + uint32_t la[2]; + if (flex_long_addr(capcode, la) < 0) return 0; + fw[astart] = la[0]; + fw[astart + 1] = la[1]; + } else { + fw[astart] = flex_short_addr(capcode); + } + + // Vector + message + if (msg_type == 0) { + fw[vstart] = flex_alpha_vector(mstart, mwc); + if (is_long && mwc > 0) { + fw[vstart + 1] = mw[0]; + for (int i = 0; i < mwc - 1 && (mstart + i) < 88; i++) + fw[mstart + i] = mw[i + 1]; + mwc = (mwc > 0) ? mwc - 1 : 0; + } else { + for (int i = 0; i < mwc && (mstart + i) < 88; i++) + fw[mstart + i] = mw[i]; + } + } else if (msg_type == 1) { + fw[vstart] = flex_numeric_vector(3, mstart, mwc, num_k); + if (is_long) { + fw[vstart + 1] = (mwc > 0) ? mw[0] : flex_enc(0); + for (int i = 1; i < mwc && (mstart + i - 1) < 88; i++) + fw[mstart + i - 1] = mw[i]; + } else { + for (int i = 0; i < mwc && (mstart + i) < 88; i++) + fw[mstart + i] = mw[i]; + } + } else { + // Type 2 = short/tone (empty msg), Type 3 = short numeric (msg digits) + std::string short_msg = (msg_type == 3) ? msg : ""; + uint32_t short_vy = 0; + fw[vstart] = flex_short_vector(is_long, short_msg, &short_vy); + if (is_long) + fw[vstart + 1] = short_vy; + } + + // Idle fill + int mf_words = mwc; + if (is_long && mwc > 0 && msg_type == 1) + mf_words = mwc - 1; + for (int i = mstart + mf_words; i < 88; i++) + fw[i] = (i & 1) ? 0x00000000 : 0xFFFFFFFF; + + for (int blk = 0; blk < 11; blk++) + flex_interleave_block(blk, fw); + + memcpy(p, fw, 88 * 4); + p += 88 * 4; + + // Trailing idle + *p++ = 0xAA; + *p++ = 0xAA; + *p++ = 0xAA; + *p++ = 0xAA; + + return (size_t)(p - buf); +} + +// ===== ERS ===== + +static size_t build_ers(uint8_t* buf, int cycles) { + uint8_t* p = buf; + uint8_t ar_inv[4]; + for (int i = 0; i < 4; i++) ar_inv[i] = ~ar[i]; + for (int c = 0; c < cycles; c++) { + *p++ = 0xAA; + *p++ = 0xAA; + memcpy(p, ar, 4); + p += 4; + *p++ = 0x55; + *p++ = 0x55; + memcpy(p, ar_inv, 4); + p += 4; + } + return (size_t)(p - buf); +} + +// ===== FlexParamsView ===== + +FlexParamsView::FlexParamsView(NavigationView& nav, FlexBIWParams& params) + : params_(params), nav_(nav) { + add_children({&labels, + &check_date, &field_year, &field_month, &field_day, + &check_time, &field_hour, &field_minute, &field_second, + &check_tz, &options_tz, &check_dst, + &check_ssid1, &field_local_id, &labels_cz, &field_coverage, + &check_ssid2, &field_country, &check_roaming, + &labels_msg, &field_msg_number, + &check_ers, &field_ers_count, + &button_save, &button_cancel}); + + // Populate from params + check_date.set_value(params_.send_date); + field_year.set_value(params_.year); + field_month.set_value(params_.month); + field_day.set_value(params_.day); + + check_time.set_value(params_.send_time); + field_hour.set_value(params_.hour); + field_minute.set_value(params_.minute); + field_second.set_value(params_.second); + + check_tz.set_value(params_.send_tz); + options_tz.set_by_value(params_.tz_code); + check_dst.set_value(params_.send_dst); + + check_ssid1.set_value(params_.send_ssid1); + field_local_id.set_value(params_.local_id); + field_coverage.set_value(params_.coverage_zone); + + check_ssid2.set_value(params_.send_ssid2); + field_country.set_value(params_.country_code); + check_roaming.set_value(params_.roaming); + + field_msg_number.set_value(params_.msg_number); + + check_ers.set_value(params_.send_ers); + field_ers_count.set_value(params_.ers_count); + + // Roaming implies SSID1 + SSID2 + check_roaming.on_select = [this](Checkbox&, bool v) { + on_roaming_changed(v); + }; + + button_save.on_select = [this, &nav](Button&) { + int32_t yr = field_year.value(); + if (yr > 2025) { + int equiv = flex_equiv_year(yr); + nav.display_modal( + "Year > 2025", + "Valid: 1994-2025\nEquiv for " + to_string_dec_uint(yr) + ": " + to_string_dec_uint(equiv)); + field_year.set_value(equiv); + return; + } + params_.send_date = check_date.value(); + params_.year = yr; + params_.month = field_month.value(); + params_.day = field_day.value(); + params_.send_time = check_time.value(); + params_.hour = field_hour.value(); + params_.minute = field_minute.value(); + params_.second = field_second.value(); + params_.send_tz = check_tz.value(); + params_.tz_code = options_tz.selected_index_value(); + params_.send_dst = check_dst.value(); + params_.send_ssid1 = check_ssid1.value(); + params_.local_id = field_local_id.value(); + params_.coverage_zone = field_coverage.value(); + params_.send_ssid2 = check_ssid2.value(); + params_.country_code = field_country.value(); + params_.roaming = check_roaming.value(); + params_.msg_number = field_msg_number.value(); + params_.send_ers = check_ers.value(); + params_.ers_count = field_ers_count.value(); + nav.pop(); + }; + + button_cancel.on_select = [&nav](Button&) { + nav.pop(); + }; +} + +void FlexParamsView::on_roaming_changed(bool v) { + if (v) { + check_ssid1.set_value(true); + check_ssid2.set_value(true); + } +} + +void FlexParamsView::focus() { + button_save.focus(); +} + +// ===== Serial message handler ===== + +void FlexTXView::on_serial_msg(const FlexTosendMessage data) { + field_capcode.set_value(data.capcode); + capcode_value = data.capcode; + options_type.set_selected_index(data.type); + message = std::string((char*)data.msg, data.msglen); + buffer = message; + text_message.set(message); + if (message.length() > 30 && message.length() <= 60) + text_message_l2.set(message.substr(29)); + else if (message.length() > 60) + text_message_l2.set(message.substr(29, 27) + "..."); + else + text_message_l2.set(""); + field_capcode.dirty(); + options_type.dirty(); + text_message.dirty(); + text_message_l2.dirty(); + tx_view.focus(); + if (start_tx()) tx_view.set_transmitting(true); +} + +// ===== TX ===== + +void FlexTXView::on_tx_progress(const uint32_t progress, const bool done) { + if (done) { + int ers_n = tx_steps_total_ - 2; + if (tx_phase_ == 1 && ers_remaining_ > 0) { + tx_step_++; + text_capinfo.set("[" + to_string_dec_uint(tx_step_) + "/" + to_string_dec_uint(tx_steps_total_) + + "] ERS " + to_string_dec_uint(tx_step_) + "/" + to_string_dec_uint(ers_n)); + uint8_t* data = shared_memory.bb_data.data; + size_t total = build_ers(data, 42); + ers_remaining_--; + uint32_t total_bits = total * 8; + progressbar.set_max(total_bits / 64); + baseband::set_fsk_data(total_bits, 2280000 / 1600, 4500, 64); + } else if (tx_phase_ == 1) { + tx_phase_ = 2; + tx_step_++; + text_capinfo.set("[" + to_string_dec_uint(tx_step_) + "/" + to_string_dec_uint(tx_steps_total_) + + "] Frame 1/2 (new)"); + send_frame(1); + } else if (tx_phase_ == 2) { + tx_phase_ = 3; + tx_step_++; + text_capinfo.set("[" + to_string_dec_uint(tx_step_) + "/" + to_string_dec_uint(tx_steps_total_) + + "] Frame 2/2 (dup)"); + send_frame(0); + } else { + biw_params_.msg_number = (biw_params_.msg_number + 1) & 63; + transmitter_model.disable(); + progressbar.set_value(0); + tx_view.set_transmitting(false); + tx_phase_ = 0; + set_dirty(); // repaint capcode info + } + } else { + progressbar.set_value(progress); + } +} + +bool FlexTXView::start_tx() { + uint64_t capcode = field_capcode.to_integer(); + capcode_value = capcode; + if (capcode < 1 || capcode > 4297068542ULL) { + nav_.display_modal("Bad capcode", "Capcode: 1-4297068542"); + return false; + } + + int msg_type = options_type.selected_index(); + if (msg_type == 1) { + if (message.find_first_not_of("0123456789 U-][") != std::string::npos) { + nav_.display_modal("Bad message", "Numeric: 0-9 U - ] [ space"); + return false; + } + } + + transmitter_model.set_sampling_rate(2280000); + transmitter_model.set_baseband_bandwidth(1'750'000); + transmitter_model.enable(); + + int ers_n = (biw_params_.send_ers && biw_params_.ers_count > 0) ? biw_params_.ers_count : 0; + tx_steps_total_ = ers_n + 2; + tx_step_ = 0; + + if (ers_n > 0) { + tx_phase_ = 1; + ers_remaining_ = ers_n; + tx_step_++; + text_capinfo.set("[" + to_string_dec_uint(tx_step_) + "/" + to_string_dec_uint(tx_steps_total_) + + "] ERS " + to_string_dec_uint(tx_step_) + "/" + to_string_dec_uint(ers_n)); + uint8_t* data = shared_memory.bb_data.data; + size_t total = build_ers(data, 42); + ers_remaining_--; + uint32_t total_bits = total * 8; + progressbar.set_max(total_bits / 64); + baseband::set_fsk_data(total_bits, 2280000 / 1600, 4500, 64); + } else { + tx_phase_ = 2; + tx_step_++; + text_capinfo.set("[" + to_string_dec_uint(tx_step_) + "/" + to_string_dec_uint(tx_steps_total_) + + "] Frame 1/2 (new)"); + send_frame(1); + } + + return true; +} + +void FlexTXView::send_frame(uint32_t msg_r) { + uint64_t capcode = field_capcode.to_integer(); + int msg_type = options_type.selected_index(); + + uint8_t* data = shared_memory.bb_data.data; + size_t total = 0; + + total += build_ers(data + total, 8); + total += build_frame(data + total, capcode, msg_type, message, 0, 0, msg_r, biw_params_); + + uint32_t total_bits = total * 8; + progressbar.set_max(total_bits / 64); + baseband::set_fsk_data(total_bits, 2280000 / 1600, 4500, 64); +} + +// ===== UI ===== + +void FlexTXView::focus() { + field_capcode.focus(); +} + +FlexTXView::~FlexTXView() { + transmitter_model.disable(); + baseband::shutdown(); +} + +void FlexTXView::paint(Painter&) { + message = buffer; + text_message.set(message); + if (message.length() > 30 && message.length() <= 60) + text_message_l2.set(message.substr(29)); + else if (message.length() > 60) + text_message_l2.set(message.substr(29, 27) + "..."); + else + text_message_l2.set(""); + + // Capcode info line + uint64_t cap = field_capcode.to_integer(); + std::string info; + if (cap == 0) { + info = "Invalid"; + } else if (cap <= 1933312ULL) { + info = "Short addr"; + } else if (cap >= 2062336ULL && cap <= 2062351ULL) { + info = "Temp grp #" + to_string_dec_uint(cap - 2062336ULL); + } else if (cap >= 2062352ULL && cap <= 2062367ULL) { + info = "Operator msg"; + } else if (cap >= 2058240ULL && cap <= 2062335ULL) { + info = "Network addr"; + } else if (cap >= 2041856ULL && cap <= 2058239ULL) { + info = "Info service"; + } else if (cap > 1933312ULL && cap < 2101249ULL) { + info = "Reserved"; + } else if (cap >= 2101249ULL && cap <= 4297068542ULL) { + info = "Long addr"; + } else { + info = "Invalid"; + } + // Computed frame/phase for all valid addresses + if (cap >= 1 && cap <= 4297068542ULL) { + static const char ph[] = "ABCD"; + int frame = (int)((cap / 16) % 128); + int phase = (int)((cap / 4) % 4); + info += ", F" + to_string_dec_uint(frame) + + " " + std::string(1, ph[phase]); + } + text_capinfo.set(info); +} + +void FlexTXView::on_set_text(NavigationView& nav) { + text_prompt(nav, buffer, MAX_FLEX_MSG, ENTER_KEYBOARD_MODE_ALPHA); +} + +FlexTXView::FlexTXView(NavigationView& nav) + : nav_(nav) { + baseband::run_prepared_image(portapack::memory::map::m4_code.base()); + + // Init random msg number from RTC + { + rtc::RTC datetime; + rtc_time::now(datetime); + int seed = datetime.second() + datetime.minute() * 7 + datetime.hour(); + biw_params_.msg_number = seed & 63; + if (biw_params_.msg_number == 0) + biw_params_.msg_number = 1; + } + + // Always populate date/time/dst from RTC + { + rtc::RTC datetime; + rtc_time::now(datetime); + int real_year = datetime.year(); + biw_params_.year = flex_equiv_year(real_year); + biw_params_.month = datetime.month(); + biw_params_.day = datetime.day(); + biw_params_.hour = datetime.hour(); + biw_params_.minute = datetime.minute(); + biw_params_.second = datetime.second(); + biw_params_.send_dst = portapack::persistent_memory::dst_enabled() ? 1 : 0; + } + + add_children({&labels, &text_capinfo, &field_capcode, &options_speed, &options_type, + &text_message, &text_message_l2, + &button_message, &button_params, &progressbar, &tx_view}); + + options_speed.set_selected_index(0); + options_type.set_selected_index(0); + field_capcode.set_value(capcode_value); + + field_capcode.on_change = [this](SymField&) { + set_dirty(); // trigger repaint to update capcode info text + }; + + button_message.on_select = [this, &nav](Button&) { + this->on_set_text(nav); + }; + + button_params.on_select = [this, &nav](Button&) { + nav.push(biw_params_); + }; + + 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); + }; + }; + + tx_view.on_start = [this]() { + if (start_tx()) tx_view.set_transmitting(true); + }; + + tx_view.on_stop = [this]() { + tx_view.set_transmitting(false); + transmitter_model.disable(); + }; +} + +} // namespace ui::external_app::flex_tx diff --git a/firmware/application/external/flex_tx/ui_flex_tx.hpp b/firmware/application/external/flex_tx/ui_flex_tx.hpp new file mode 100644 index 000000000..d3a8b1d9f --- /dev/null +++ b/firmware/application/external/flex_tx/ui_flex_tx.hpp @@ -0,0 +1,251 @@ +#ifndef __UI_FLEX_TX_H__ +#define __UI_FLEX_TX_H__ + +#include "ui.hpp" +#include "ui_widget.hpp" +#include "ui_navigation.hpp" +#include "ui_receiver.hpp" +#include "ui_transmitter.hpp" +#include "message.hpp" +#include "transmitter_model.hpp" +#include "app_settings.hpp" +#include "radio_state.hpp" + +namespace ui::external_app::flex_tx { + +struct FlexBIWParams { + int32_t send_date{1}; + int32_t send_time{1}; + int32_t send_tz{1}; + int32_t send_dst{0}; + int32_t send_ssid1{0}; + int32_t send_ssid2{0}; + int32_t roaming{0}; + int32_t year{2015}; + int32_t month{1}; + int32_t day{1}; + int32_t hour{0}; + int32_t minute{0}; + int32_t second{0}; + int32_t tz_code{0}; + int32_t local_id{0}; + int32_t coverage_zone{0}; + int32_t country_code{0}; + int32_t msg_number{0}; + int32_t send_ers{1}; + int32_t ers_count{1}; +}; + +class FlexParamsView : public View { + public: + FlexParamsView(NavigationView& nav, FlexBIWParams& params); + + std::string title() const override { return "FLEX Params"; }; + void focus() override; + + private: + FlexBIWParams& params_; + NavigationView& nav_; + + void on_roaming_changed(bool v); + + Labels labels{ + {{12 * 8, 1 * 16}, "-", Theme::getInstance()->fg_light->foreground}, + {{15 * 8, 1 * 16}, "-", Theme::getInstance()->fg_light->foreground}, + {{10 * 8, 2 * 16}, ":", Theme::getInstance()->fg_light->foreground}, + {{13 * 8, 2 * 16}, ":", Theme::getInstance()->fg_light->foreground}, + }; + + Checkbox check_date{{0 * 8, 1 * 16}, 4, "Date", true}; + NumberField field_year{{8 * 8, 1 * 16}, 4, {1994, 2099}, 1, '0', true}; + NumberField field_month{{13 * 8, 1 * 16}, 2, {1, 12}, 1, '0', true}; + NumberField field_day{{16 * 8, 1 * 16}, 2, {1, 31}, 1, '0', true}; + + Checkbox check_time{{0 * 8, 2 * 16}, 4, "Time", true}; + NumberField field_hour{{8 * 8, 2 * 16}, 2, {0, 23}, 1, '0', true}; + NumberField field_minute{{11 * 8, 2 * 16}, 2, {0, 59}, 1, '0', true}; + NumberField field_second{{14 * 8, 2 * 16}, 2, {0, 59}, 1, '0', true}; + + Checkbox check_tz{{0 * 8, 3 * 16}, 2, "TZ", true}; + OptionsField options_tz{ + {6 * 8, 3 * 16}, + 9, + {{"UTC+0 ", 0}, + {"UTC+1 ", 1}, + {"UTC+2 ", 2}, + {"UTC+3 ", 3}, + {"UTC+4 ", 4}, + {"UTC+5 ", 5}, + {"UTC+6 ", 6}, + {"UTC+7 ", 7}, + {"UTC+8 ", 8}, + {"UTC+9 ", 9}, + {"UTC+10 ", 10}, + {"UTC+11 ", 11}, + {"UTC+12 ", 12}, + {"UTC+3:30", 13}, + {"UTC+4:30", 14}, + {"UTC+5:30", 15}, + {"UTC+5:45", 17}, + {"UTC+6:30", 18}, + {"UTC+9:30", 19}, + {"UTC-3:30", 20}, + {"UTC-11 ", 21}, + {"UTC-10 ", 22}, + {"UTC-9 ", 23}, + {"UTC-8 ", 24}, + {"UTC-7 ", 25}, + {"UTC-6 ", 26}, + {"UTC-5 ", 27}, + {"UTC-4 ", 28}, + {"UTC-3 ", 29}, + {"UTC-2 ", 30}, + {"UTC-1 ", 31}}}; + Checkbox check_dst{{18 * 8, 3 * 16}, 3, "DST", true}; + + Checkbox check_ssid1{{0 * 8, 5 * 16}, 5, "LocID", true}; + NumberField field_local_id{{9 * 8, 5 * 16}, 3, {0, 511}, 1, '0'}; + Labels labels_cz{ + {{12 * 8, 5 * 16}, "CovZone", Theme::getInstance()->fg_light->foreground}, + }; + NumberField field_coverage{{20 * 8, 5 * 16}, 2, {0, 31}, 1, '0'}; + + Checkbox check_ssid2{{0 * 8, 6 * 16}, 11, "CountryCode", true}; + NumberField field_country{{15 * 8, 6 * 16}, 4, {0, 1023}, 1, '0'}; + Checkbox check_roaming{{0 * 8, 7 * 16}, 4, "Roam", true}; + + Labels labels_msg{ + {{0 * 8, 9 * 16}, "Msg#:", Theme::getInstance()->fg_light->foreground}, + }; + NumberField field_msg_number{{5 * 8, 9 * 16}, 2, {0, 63}, 1, '0'}; + + Checkbox check_ers{{0 * 8, 10 * 16}, 3, "ERS", true}; + NumberField field_ers_count{{8 * 8, 10 * 16}, 2, {1, 10}, 1, ' '}; + + Button button_save{{1 * 8, 12 * 16, 12 * 8, 32}, "Save"}; + Button button_cancel{{16 * 8, 12 * 16, 12 * 8, 32}, "Cancel"}; +}; + +class FlexTXView : public View { + public: + FlexTXView(NavigationView& nav); + ~FlexTXView(); + + FlexTXView(const FlexTXView&) = delete; + FlexTXView& operator=(const FlexTXView&) = delete; + + void focus() override; + void paint(Painter&) override; + std::string title() const override { return "FLEX TX"; }; + + FlexBIWParams biw_params_{}; + + private: + std::string buffer{"FLEX TEST"}; + std::string message{}; + NavigationView& nav_; + int tx_phase_{0}; + int ers_remaining_{0}; + int tx_step_{0}; + int tx_steps_total_{0}; + + int64_t capcode_value{1000}; + + TxRadioState radio_state_{ + 931740000 /* frequency */, + 1750000 /* bandwidth */, + 2280000 /* sampling rate */ + }; + app_settings::SettingsManager settings_{ + "tx_flex", + app_settings::Mode::TX, + {{"capcode", &capcode_value}, + {"biw_date", &biw_params_.send_date}, + {"biw_time", &biw_params_.send_time}, + {"biw_tz", &biw_params_.send_tz}, + {"biw_ssid1", &biw_params_.send_ssid1}, + {"biw_ssid2", &biw_params_.send_ssid2}, + {"biw_roam", &biw_params_.roaming}, + {"biw_tzc", &biw_params_.tz_code}, + {"biw_lid", &biw_params_.local_id}, + {"biw_cz", &biw_params_.coverage_zone}, + {"biw_cc", &biw_params_.country_code}, + {"biw_ers", &biw_params_.send_ers}, + {"biw_ersc", &biw_params_.ers_count}}}; + + void on_set_text(NavigationView& nav); + void on_tx_progress(const uint32_t progress, const bool done); + void on_serial_msg(const FlexTosendMessage data); + bool start_tx(); + void send_frame(uint32_t msg_r); + + Labels labels{ + {{1 * 8, 4 * 8}, "Capcode:", Theme::getInstance()->fg_light->foreground}, + {{3 * 8, 6 * 8}, "Speed:", Theme::getInstance()->fg_light->foreground}, + {{4 * 8, 8 * 8}, "Type:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), 12 * 8}, "Message:", Theme::getInstance()->fg_light->foreground}}; + + Text text_capinfo{ + {UI_POS_X(0), 2 * 8, screen_width, 16}, + ""}; + + SymField field_capcode{ + {10 * 8, 4 * 8}, + 10}; + + OptionsField options_speed{ + {10 * 8, 6 * 8}, + 10, + {{"1600/2FSK ", 0}}}; + + OptionsField options_type{ + {10 * 8, 8 * 8}, + 14, + {{"Alphanumeric ", 0}, + {"Numeric ", 1}, + {"Short/tone ", 2}, + {"Short numeric", 3}}}; + + Text text_message{ + {UI_POS_X(0), 14 * 8, screen_width, 16}, + ""}; + Text text_message_l2{ + {UI_POS_X(0), 16 * 8, screen_width, 16}, + ""}; + + Button button_message{ + {UI_POS_X(0), 18 * 8, 13 * 8, 32}, + "Set message"}; + + Button button_params{ + {14 * 8, 18 * 8, 13 * 8, 32}, + "Set params"}; + + ProgressBar progressbar{ + {16, 210, UI_POS_WIDTH_REMAINING(4), 16}}; + + TransmitterView tx_view{ + (int16_t)UI_POS_Y_BOTTOM(4), + 10000, + 9}; + + 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_flex_tosend{ + Message::ID::FlexTosend, + [this](const Message* const p) { + const auto message = + *reinterpret_cast(p); + this->on_serial_msg(message); + }}; +}; + +} // namespace ui::external_app::flex_tx + +#endif diff --git a/firmware/application/usb_serial_shell.cpp b/firmware/application/usb_serial_shell.cpp index 9410ef120..868e76e6a 100644 --- a/firmware/application/usb_serial_shell.cpp +++ b/firmware/application/usb_serial_shell.cpp @@ -1353,6 +1353,53 @@ static void cmd_sendpocsag(BaseSequentialStream* chp, int argc, char* argv[]) { chprintf(chp, "ok\r\n"); } +static void cmd_sendflex(BaseSequentialStream* chp, int argc, char* argv[]) { + const char* usage = "usage: sendflex \r\n type: 0=alpha 1=numeric 2=short/tone 3=short numeric\r\n"; + if (argc < 3) { + chprintf(chp, usage); + return; + } + uint64_t capcode = strtoull(argv[0], nullptr, 10); + if (capcode < 1 || capcode > 4297068542ULL) { + chprintf(chp, "error, capcode 1-4297068542\r\n"); + return; + } + int type = atoi(argv[1]); + if (type < 0 || type > 3) { + chprintf(chp, "error, type 0=alpha 1=numeric 2=short/tone 3=short numeric\r\n"); + return; + } + int msglen = atoi(argv[2]); + if (msglen < 0 || msglen > 240) { + chprintf(chp, "error, msglen max 240\r\n"); + return; + } + + uint8_t msg[240] = {0}; + if (msglen > 0) { + chprintf(chp, "send %d bytes\r\n", msglen); + int offset = 0; + do { + size_t bytes_to_read = (msglen - offset) > USB_BULK_BUFFER_SIZE ? USB_BULK_BUFFER_SIZE : (msglen - offset); + size_t bytes_read = chSequentialStreamRead(chp, &msg[offset], bytes_to_read); + if (bytes_read != bytes_to_read) + return; + offset += bytes_read; + } while (offset < msglen); + } + + auto evtd = getEventDispatcherInstance(); + if (!evtd) return; + auto top_widget = evtd->getTopWidget(); + if (!top_widget) return; + auto nav = static_cast(top_widget)->get_navigation_view(); + if (!nav) return; + + FlexTosendMessage message{capcode, (uint8_t)type, (uint8_t)msglen, msg}; + EventDispatcher::send_message(message); + chprintf(chp, "ok\r\n"); +} + static void cmd_asyncmsg(BaseSequentialStream* chp, int argc, char* argv[]) { const char* usage = "usage: asyncmsg x, x can be enable or disable\r\n"; if (argc != 1) { @@ -1500,6 +1547,7 @@ static const ShellCommand commands[] = { {"pmemreset", cmd_pmemreset}, {"settingsreset", cmd_settingsreset}, {"sendpocsag", cmd_sendpocsag}, + {"sendflex", cmd_sendflex}, {"asyncmsg", cmd_asyncmsg}, {"setfreq", cmd_setfreq}, {"getres", cmd_getres}, diff --git a/firmware/common/message.hpp b/firmware/common/message.hpp index ef19cdd78..7490c12fd 100644 --- a/firmware/common/message.hpp +++ b/firmware/common/message.hpp @@ -160,6 +160,7 @@ class Message { P25TxConfigure = 102, ToneDetectData = 103, ToneDetectConfig = 104, + FlexTosend = 105, MAX }; @@ -1884,4 +1885,24 @@ class ToneDetectConfigureMessage : public Message { uint32_t ctcss_freq_x10{0}; // CTCSS frequency × 10 (e.g. 1000 = 100.0 Hz); 0 = None }; +class FlexTosendMessage : public Message { + public: + constexpr FlexTosendMessage( + uint64_t capcode = 0, + uint8_t type = 0, + uint8_t msglen = 0, + const uint8_t* msg = nullptr) + : Message{ID::FlexTosend}, + capcode{capcode}, + type{type}, + msglen{msglen} { + if (msg) + memcpy(this->msg, msg, 240); + } + uint64_t capcode = 0; + uint8_t type = 0; + uint8_t msglen = 0; + uint8_t msg[240] = {0}; +}; + #endif /*__MESSAGE_H__*/