From dd006b48303dc90508495f8125b68cd01ad8de34 Mon Sep 17 00:00:00 2001 From: VasylSamoilov <20946668+VasylSamoilov@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:35:43 +0300 Subject: [PATCH] POCSAG: decoder fixes, serial output, TX polarity cleanup (#3130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RX: fix address/batch-boundary handling, add numeric continuation, add heuristic type detection toggle, pass inverted polarity flag, stream decoded messages over USB serial. TX: rename phase to polarity (Standard/Inverted), fix function validation (&&→||), fix serial shell payload read (offset tracking, 31-byte cap, preserve msglen). Decoder: remove color escapes from output (plain text for all consumers), guard count_alpha_fill against empty string, keep numeric_len across continuation batches. Serial format: POCSAG "" hex: Quote escaping for embedded " and \\ in alpha messages. --- firmware/application/apps/pocsag_app.cpp | 141 +++++++- firmware/application/apps/pocsag_app.hpp | 21 +- .../external/pocsag_tx/ui_pocsag_tx.cpp | 30 +- .../external/pocsag_tx/ui_pocsag_tx.hpp | 18 +- firmware/application/usb_serial_shell.cpp | 46 ++- firmware/baseband/proc_pocsag2.cpp | 10 +- firmware/baseband/proc_pocsag2.hpp | 3 + firmware/common/message.hpp | 10 +- firmware/common/pocsag.cpp | 315 ++++++++++++++++-- firmware/common/pocsag.hpp | 34 +- firmware/common/pocsag_packet.hpp | 13 +- 11 files changed, 549 insertions(+), 92 deletions(-) diff --git a/firmware/application/apps/pocsag_app.cpp b/firmware/application/apps/pocsag_app.cpp index f2a122d35..b0f35bea8 100644 --- a/firmware/application/apps/pocsag_app.cpp +++ b/firmware/application/apps/pocsag_app.cpp @@ -28,6 +28,7 @@ #include "string_format.hpp" #include "utility.hpp" #include "file_path.hpp" +#include "usb_serial_asyncmsg.hpp" using namespace portapack; using namespace pocsag; @@ -62,6 +63,7 @@ POCSAGSettingsView::POCSAGSettingsView( &check_small_font, &check_hide_bad, &check_hide_addr_only, + &check_numeric_detect, &opt_filter_mode, &field_filter_address, &button_save}); @@ -72,6 +74,7 @@ POCSAGSettingsView::POCSAGSettingsView( check_small_font.set_value(settings_.enable_small_font); check_hide_bad.set_value(settings_.hide_bad_data); check_hide_addr_only.set_value(settings_.hide_addr_only); + check_numeric_detect.set_value(settings_.enable_numeric_detect); opt_filter_mode.set_by_value(settings_.filter_mode); field_filter_address.set_value(settings_.filter_address); @@ -81,6 +84,7 @@ POCSAGSettingsView::POCSAGSettingsView( settings_.enable_small_font = check_small_font.value(); settings_.hide_bad_data = check_hide_bad.value(); settings_.hide_addr_only = check_hide_addr_only.value(); + settings_.enable_numeric_detect = check_numeric_detect.value(); settings_.filter_mode = opt_filter_mode.selected_index_value(); settings_.filter_address = field_filter_address.to_integer(); settings_.baud_rate = opt_baud_rate.selected_index_value(); @@ -219,12 +223,24 @@ void POCSAGAppView::handle_decoded(Timestamp timestamp, const std::string& prefi return; } - // Color indicates the message has a lot of decoding errors. - std::string color = bad_data ? STR_COLOR_MAGENTA : STR_COLOR_WHITE; + // Type indicator with its own color. + std::string type_str; + bool numeric_detect = settings_.enable_numeric_detect; + if (numeric_detect && pocsag_state.detected == pocsag::DET_NUMERIC) + type_str = STR_COLOR_GREEN "n"; + else if (numeric_detect && pocsag_state.detected == pocsag::DET_ALPHA) + type_str = STR_COLOR_LIGHT_GREY "a"; + else if (pocsag_state.out_type == ADDRESS) + type_str = STR_COLOR_DARK_YELLOW "t"; + else + type_str = ""; - std::string console_info = "\n" + color + prefix; - console_info += " #" + to_string_dec_uint(pocsag_state.address); + // Header: timestamp+baud in light grey, capcode+function in white. + std::string console_info = "\n" STR_COLOR_LIGHT_GREY + prefix; + console_info += STR_COLOR_WHITE " #" + to_string_dec_uint(pocsag_state.address); console_info += " F" + to_string_dec_uint(pocsag_state.function); + if (!type_str.empty()) + console_info += " " + type_str; if (pocsag_state.out_type == ADDRESS) { last_address = pocsag_state.address; @@ -240,23 +256,110 @@ void POCSAGAppView::handle_decoded(Timestamp timestamp, const std::string& prefi } } + /* Serial: tone-only page */ + if (portapack::usb_serial.serial_connected()) { + std::string s = "\r\nPOCSAG " + to_string_dec_uint(current_bitrate) + " " + to_string_dec_uint(pocsag_state.address) + " " + std::string(1, 'A' + pocsag_state.function) + (current_inverted ? " I" : " S") + " tone"; + UsbSerialAsyncmsg::asyncmsg(s); + } + } else if (pocsag_state.out_type == MESSAGE) { - if (pocsag_state.address != last_address) { - // New message + if (pocsag_state.new_message) { last_address = pocsag_state.address; + serial_numeric_sent = 0; console.writeln(console_info); - console.write(color + pocsag_state.output); + + // If heuristic chose numeric, show numeric line first (green). + if (numeric_detect && + pocsag_state.detected == pocsag::DET_NUMERIC && + pocsag_state.numeric_len > 0) { + std::string num_str(pocsag_state.numeric_buf, pocsag_state.numeric_len); + console.write(STR_COLOR_GREEN + num_str); + console.writeln(""); + } + + // Alpha decode (already has per-char color escapes from decoder). + console.write(pocsag_state.output); + + /* Serial: header + first chunk. + * hex field contains rendered alpha as hex bytes (color escapes + * stripped). Non-printable and uncorrectable chars show as '.' + * (0x2E) — original 7-bit values are not preserved. + * Numeric decode goes in the quoted message field only. */ + if (portapack::usb_serial.serial_connected()) { + /* Build hex representation of decoded alpha characters. */ + std::string raw; + for (size_t i = 0; i < pocsag_state.output.size(); ++i) + raw += to_string_hex((uint8_t)pocsag_state.output[i], 2); + + std::string s = "\r\nPOCSAG " + to_string_dec_uint(current_bitrate) + " " + to_string_dec_uint(pocsag_state.address) + " " + std::string(1, 'A' + pocsag_state.function) + (current_inverted ? " I" : " S"); + + if (numeric_detect && pocsag_state.detected == pocsag::DET_NUMERIC) { + s += " numeric \""; + if (pocsag_state.numeric_len > 0) + s += std::string(pocsag_state.numeric_buf, pocsag_state.numeric_len); + s += "\""; + serial_numeric_sent = pocsag_state.numeric_len; + } else { + /* For alpha, the quoted text is the same chars as raw but as ASCII. + * Escape " and \ to avoid breaking the quoted field. */ + s += " alpha \""; + for (size_t i = 0; i < pocsag_state.output.size(); ++i) { + if (pocsag_state.output[i] == '"' || pocsag_state.output[i] == '\\') { + s += '\\'; + } + s += pocsag_state.output[i]; + } + s += "\""; + } + s += " hex:" + raw; + UsbSerialAsyncmsg::asyncmsg(s); + } } else { - // Message continues... - console.write(color + pocsag_state.output); + // Message continues from previous batch. + bool is_numeric = numeric_detect && pocsag_state.detected == pocsag::DET_NUMERIC; + + // GUI: show numeric continuation if applicable. + if (is_numeric && pocsag_state.numeric_len > serial_numeric_sent) { + std::string num_str(pocsag_state.numeric_buf + serial_numeric_sent, + pocsag_state.numeric_len - serial_numeric_sent); + console.write(STR_COLOR_GREEN + num_str); + } + console.write(pocsag_state.output); + + /* Serial: continuation chunk with full header. + * Type label carries over from first batch (numeric+ or alpha+). */ + if (portapack::usb_serial.serial_connected()) { + std::string raw; + std::string decoded; + for (size_t i = 0; i < pocsag_state.output.size(); ++i) { + if (pocsag_state.output[i] == '"' || pocsag_state.output[i] == '\\') + decoded += '\\'; + decoded += pocsag_state.output[i]; + raw += to_string_hex((uint8_t)pocsag_state.output[i], 2); + } + if (!decoded.empty() || pocsag_state.numeric_len > serial_numeric_sent) { + std::string s = "\r\nPOCSAG " + to_string_dec_uint(current_bitrate) + " " + to_string_dec_uint(pocsag_state.address) + " " + std::string(1, 'A' + pocsag_state.function) + (current_inverted ? " I" : " S") + (is_numeric ? " numeric+" : " alpha+") + " \""; + if (is_numeric && pocsag_state.numeric_len > serial_numeric_sent) + s += std::string(pocsag_state.numeric_buf + serial_numeric_sent, + pocsag_state.numeric_len - serial_numeric_sent); + else + s += decoded; + s += "\" hex:" + raw; + UsbSerialAsyncmsg::asyncmsg(s); + } + } + serial_numeric_sent = pocsag_state.numeric_len; } if (logging()) { - logger.log_decoded( - timestamp, - to_string_dec_uint(pocsag_state.address) + - " F" + to_string_dec_uint(pocsag_state.function) + - " " + pocsag_state.output); + std::string log_entry = to_string_dec_uint(pocsag_state.address) + + " F" + to_string_dec_uint(pocsag_state.function); + if (numeric_detect && + pocsag_state.detected == pocsag::DET_NUMERIC && + pocsag_state.numeric_len > 0) + log_entry += " N:" + std::string(pocsag_state.numeric_buf, pocsag_state.numeric_len); + log_entry += " " + pocsag_state.output; + logger.log_decoded(timestamp, log_entry); } } } @@ -300,13 +403,19 @@ void POCSAGAppView::on_packet(const POCSAGPacketMessage* message) { image_status.set_foreground(Theme::getInstance()->fg_magenta->foreground); pocsag_state.codeword_index = 0; pocsag_state.errors = 0; + current_bitrate = message->packet.bitrate(); + current_inverted = message->packet.inverted(); // Handle multiple messages (if any). while (pocsag_decode_batch(message->packet, pocsag_state)) handle_decoded(message->packet.timestamp(), prefix); - // Handle the remainder. - handle_decoded(message->packet.timestamp(), prefix); + // Handle the remainder. Skip if decoder is still in + // STATE_HAVE_ADDRESS — the address was at the end of this + // batch and we can't yet tell if it's tone-only or has + // message data in the next batch. + if (pocsag_state.mode != STATE_HAVE_ADDRESS) + handle_decoded(message->packet.timestamp(), prefix); } // Set status icon color to indicate state machine state. diff --git a/firmware/application/apps/pocsag_app.hpp b/firmware/application/apps/pocsag_app.hpp index e5527b1f0..1812db149 100644 --- a/firmware/application/apps/pocsag_app.hpp +++ b/firmware/application/apps/pocsag_app.hpp @@ -125,6 +125,7 @@ struct POCSAGSettings { bool enable_raw_log = false; bool hide_bad_data = false; bool hide_addr_only = false; + bool enable_numeric_detect = true; /* heuristic alpha/numeric type detection */ uint8_t filter_mode = false; int32_t baud_rate = -1; uint32_t filter_address = 0; @@ -150,8 +151,8 @@ class POCSAGSettingsView : public View { Labels labels{ {{2 * 8, 0 * 16}, "Baud:", Theme::getInstance()->fg_light->foreground}, - {{2 * 8, 12 * 16}, "Filter Mode:", Theme::getInstance()->fg_light->foreground}, - {{2 * 8, 13 * 16}, "Filter Addr:", Theme::getInstance()->fg_light->foreground}, + {{2 * 8, 14 * 16}, "Filter Mode:", Theme::getInstance()->fg_light->foreground}, + {{2 * 8, 15 * 16}, "Filter Addr:", Theme::getInstance()->fg_light->foreground}, }; Checkbox check_log{ @@ -179,21 +180,26 @@ class POCSAGSettingsView : public View { 22, "Hide Addr Only"}; + Checkbox check_numeric_detect{ + {2 * 8, 12 * 16}, + 22, + "Detect Numeric"}; + OptionsField opt_filter_mode{ - {15 * 8, 12 * 16}, + {15 * 8, 14 * 16}, 4, {{"None", FILTER_NONE}, {"Drop", FILTER_DROP}, {"Keep", FILTER_KEEP}}}; SymField field_filter_address{ - {15 * 8, 13 * 16}, + {15 * 8, 15 * 16}, 7, SymField::Type::Dec, true /*explicit_edit*/}; Button button_save{ - {UI_POS_X_CENTER(10), UI_POS_Y(16), 10 * 8, 2 * 16}, + {UI_POS_X_CENTER(10), UI_POS_Y(17), 10 * 8, 2 * 16}, "Save"}; }; @@ -231,6 +237,7 @@ class POCSAGAppView : public View { {"filter_address"sv, &settings_.filter_address}, {"hide_bad_data"sv, &settings_.hide_bad_data}, {"hide_addr_only"sv, &settings_.hide_addr_only}, + {"numeric_detect"sv, &settings_.enable_numeric_detect}, {"baud_rate"sv, &settings_.baud_rate}, }}; @@ -241,6 +248,10 @@ class POCSAGAppView : public View { void on_stats(const POCSAGStatsMessage* stats); uint32_t last_address = 0; + uint16_t current_bitrate = 0; /* bitrate of current packet being decoded */ + bool current_inverted = false; /* polarity of current packet being decoded */ + uint8_t serial_numeric_sent = 0; /* numeric chars already sent to serial/GUI */ + pocsag::EccContainer ecc{}; pocsag::POCSAGState pocsag_state{&ecc}; POCSAGLogger logger{}; diff --git a/firmware/application/external/pocsag_tx/ui_pocsag_tx.cpp b/firmware/application/external/pocsag_tx/ui_pocsag_tx.cpp index 7c7a841aa..e8eaf13b2 100644 --- a/firmware/application/external/pocsag_tx/ui_pocsag_tx.cpp +++ b/firmware/application/external/pocsag_tx/ui_pocsag_tx.cpp @@ -56,7 +56,8 @@ void POCSAGTXView::on_remete(const PocsagTosendMessage data) { if (data.function == 'C') tmp = 2; if (data.function == 'D') tmp = 3; options_function.set_selected_index(tmp); - options_phase.set_selected_index(data.phase == 'P' ? 0 : 1); + /* 'S' = Standard (CCIR Rec. 584 polarity), 'I' = Inverted */ + options_polarity.set_selected_index(data.polarity == 'S' ? 0 : 1); field_address.set_value(data.addr); message = (char*)data.msg; buffer = message; @@ -64,7 +65,7 @@ void POCSAGTXView::on_remete(const PocsagTosendMessage data) { options_bitrate.dirty(); options_type.dirty(); options_function.dirty(); - options_phase.dirty(); + options_polarity.dirty(); field_address.dirty(); text_message.dirty(); tx_view.focus(); @@ -100,7 +101,17 @@ bool POCSAGTXView::start_tx() { return false; } } - MessageType phase = (MessageType)options_phase.selected_index_value(); + /* + * TX polarity inversion (CCIR Rec. 584): + * + * The FSK modulator (proc_fsk.cpp) maps bit 1 to positive deviation. + * Standard POCSAG requires bit 1 to negative deviation. + * + * "Standard" (value 0): invert codewords so that original bit 1 becomes + * bit 0 in the modulator, producing negative deviation. This is the standard. + * "Inverted" (value 1): send codewords as-is. Bit 1 produces positive deviation. + */ + bool invert_polarity = (options_polarity.selected_index_value() == 0); pocsag_encode(type, BCH_code, options_function.selected_index_value(), message, address, codewords); @@ -118,10 +129,7 @@ bool POCSAGTXView::start_tx() { bi = 0; for (i = 0; i < codewords.size(); i++) { - if (phase == 0) - codeword = ~(codewords[i]); - else - codeword = codewords[i]; + codeword = invert_polarity ? ~(codewords[i]) : codewords[i]; data_ptr[bi++] = (codeword >> 24) & 0xFF; data_ptr[bi++] = (codeword >> 16) & 0xFF; @@ -166,15 +174,17 @@ POCSAGTXView::POCSAGTXView( &field_address, &options_type, &options_function, - &options_phase, + &options_polarity, &text_message, &text_message_l2, &button_message, &progressbar, &tx_view}); - options_bitrate.set_selected_index(1); // 1200bps - options_type.set_selected_index(0); // Address only + options_bitrate.set_selected_index(1); // 1200 bps + options_type.set_selected_index(2); // Alphanumeric + options_function.set_selected_index(0); // Function A + options_polarity.set_selected_index(0); // Standard (CCIR Rec. 584) field_address.set_value(persistent_memory::pocsag_last_address()); diff --git a/firmware/application/external/pocsag_tx/ui_pocsag_tx.hpp b/firmware/application/external/pocsag_tx/ui_pocsag_tx.hpp index 0005688ce..48ec14dec 100644 --- a/firmware/application/external/pocsag_tx/ui_pocsag_tx.hpp +++ b/firmware/application/external/pocsag_tx/ui_pocsag_tx.hpp @@ -84,7 +84,7 @@ class POCSAGTXView : public View { {{3 * 8, 6 * 8}, "Address:", Theme::getInstance()->fg_light->foreground}, {{6 * 8, 8 * 8}, "Type:", Theme::getInstance()->fg_light->foreground}, {{2 * 8, 10 * 8}, "Function:", Theme::getInstance()->fg_light->foreground}, - {{5 * 8, 12 * 8}, "Phase:", Theme::getInstance()->fg_light->foreground}, + {{2 * 8, 12 * 8}, "Polarity:", Theme::getInstance()->fg_light->foreground}, {{UI_POS_X(0), 14 * 8}, "Message:", Theme::getInstance()->fg_light->foreground}}; OptionsField options_bitrate{ @@ -113,12 +113,20 @@ class POCSAGTXView : public View { {"C", 2}, {"D", 3}}}; - OptionsField options_phase{ + /* + * TX polarity (CCIR Rec. 584): + * "Standard" (value 0): bit 1 = negative deviation (CCIR Rec. 584 standard). + * Codewords are bitwise-inverted before the FSK modulator + * because the modulator maps bit 1 to positive deviation. + * "Inverted" (value 1): bit 1 = positive deviation (non-standard). + * Codewords are sent as-is to the modulator. + */ + OptionsField options_polarity{ {11 * 8, 12 * 8}, - 1, + 8, { - {"P", 0}, - {"N", 1}, + {"Standard", 0}, + {"Inverted", 1}, }}; Text text_message{ diff --git a/firmware/application/usb_serial_shell.cpp b/firmware/application/usb_serial_shell.cpp index 9c1483f89..9410ef120 100644 --- a/firmware/application/usb_serial_shell.cpp +++ b/firmware/application/usb_serial_shell.cpp @@ -1262,7 +1262,7 @@ static void cmd_settingsreset(BaseSequentialStream* chp, int argc, char* argv[]) } static void cmd_sendpocsag(BaseSequentialStream* chp, int argc, char* argv[]) { - const char* usage = "usage: sendpocsag [baud] [type] [function] [phase] \r\n"; + const char* usage = "usage: sendpocsag [baud] [type] [function] [polarity:S|I] \r\n"; (void)argv; if (argc < 2) { chprintf(chp, usage); @@ -1293,34 +1293,48 @@ static void cmd_sendpocsag(BaseSequentialStream* chp, int argc, char* argv[]) { } } - char function = 'D'; + char function = 'A'; if (argc >= 5) { function = *argv[4]; - if (function < 'A' && function > 'D') { + if (function < 'A' || function > 'D') { chprintf(chp, "error, function can be A, B, C or D\r\n"); return; } } - char phase = 'P'; + char polarity = 'S'; /* Standard = CCIR Rec. 584 (bit 1 = negative deviation) */ if (argc >= 6) { - phase = *argv[5]; - if (phase != 'P' && phase != 'N') { - chprintf(chp, "error, phase can be P or N\r\n"); + polarity = *argv[5]; + /* + * Legacy compatibility: old firmware used 'P'/'N' with opposite semantics. + * Old 'P' (phase=positive) = inverted codewords = standard POCSAG = new 'S' (Standard) + * Old 'N' (phase=negative) = no inversion = inverted POCSAG = new 'I' (Inverted) + * New firmware uses 'S'/'I': + * 'S' = Standard (CCIR Rec. 584, bit 1 = negative deviation) + * 'I' = Inverted (bit 1 = positive deviation) + */ + if (polarity == 'P') polarity = 'S'; /* legacy 'P' maps to Standard */ + if (polarity == 'N') polarity = 'I'; /* legacy 'N' maps to Inverted */ + if (polarity != 'S' && polarity != 'I') { + chprintf(chp, "error, polarity can be S (Standard) or I (Inverted)\r\n"); return; } } - uint8_t msg[81] = {0}; - if (msglen > 0) { - chprintf(chp, "send %d bytes\r\n", msglen); - do { - size_t bytes_to_read = msglen > USB_BULK_BUFFER_SIZE ? USB_BULK_BUFFER_SIZE : msglen; - size_t bytes_read = chSequentialStreamRead(chp, &msg[0], bytes_to_read); + uint8_t msg[31] = {0}; + uint8_t original_msglen = (msglen > 31) ? 31 : msglen; + if (original_msglen > 0) { + chprintf(chp, "send %d bytes\r\n", original_msglen); + size_t offset = 0; + size_t remaining = original_msglen; + while (remaining > 0) { + size_t bytes_to_read = remaining > USB_BULK_BUFFER_SIZE ? USB_BULK_BUFFER_SIZE : remaining; + size_t bytes_read = chSequentialStreamRead(chp, &msg[offset], bytes_to_read); if (bytes_read != bytes_to_read) return; - msglen -= bytes_read; - } while (msglen > 0); + offset += bytes_read; + remaining -= bytes_read; + } } auto evtd = getEventDispatcherInstance(); @@ -1334,7 +1348,7 @@ static void cmd_sendpocsag(BaseSequentialStream* chp, int argc, char* argv[]) { return; } chThdSleepMilliseconds(1000); // wait for app to start - PocsagTosendMessage message{(uint16_t)baud, (uint8_t)type, function, phase, (uint8_t)msglen, msg, addr}; + PocsagTosendMessage message{(uint16_t)baud, (uint8_t)type, function, polarity, original_msglen, msg, addr}; EventDispatcher::send_message(message); chprintf(chp, "ok\r\n"); } diff --git a/firmware/baseband/proc_pocsag2.cpp b/firmware/baseband/proc_pocsag2.cpp index 590a5dda7..eb69e9296 100644 --- a/firmware/baseband/proc_pocsag2.cpp +++ b/firmware/baseband/proc_pocsag2.cpp @@ -38,14 +38,7 @@ using namespace std; namespace { /* Count of bits that differ between the two values. */ uint8_t diff_bit_count(uint32_t left, uint32_t right) { - uint32_t diff = left ^ right; - uint8_t count = 0; - for (size_t i = 0; i < sizeof(diff) * 8; ++i) { - if (((diff >> i) & 0x1) == 1) - ++count; - } - - return count; + return __builtin_popcount(left ^ right); } } // namespace @@ -426,6 +419,7 @@ void POCSAGProcessor::send_packet() { packet.set_flag(pocsag::PacketFlag::NORMAL); packet.set_timestamp(Timestamp::now()); packet.set_bitrate(bit_extractor.baud_rate()); + packet.set_inverted(word_extractor.inverted()); packet.set(word_extractor.batch()); POCSAGPacketMessage message(packet); diff --git a/firmware/baseband/proc_pocsag2.hpp b/firmware/baseband/proc_pocsag2.hpp index 23fe536a9..a46210906 100644 --- a/firmware/baseband/proc_pocsag2.hpp +++ b/firmware/baseband/proc_pocsag2.hpp @@ -153,6 +153,9 @@ class CodewordExtractor { /* Returns true if the batch has as sync frame. */ bool has_sync() const { return has_sync_; } + /* Returns true if the signal was received with inverted polarity. */ + bool inverted() const { return inverted_; } + private: /* Sync frame codeword. */ static constexpr uint32_t sync_codeword = 0x7cd215d8; diff --git a/firmware/common/message.hpp b/firmware/common/message.hpp index 91b99dd97..ef19cdd78 100644 --- a/firmware/common/message.hpp +++ b/firmware/common/message.hpp @@ -1569,8 +1569,8 @@ class PocsagTosendMessage : public Message { constexpr PocsagTosendMessage( uint16_t baud = 1200, uint8_t type = 2, - char function = 'D', - char phase = 'N', + char function = 'A', + char polarity = 'S', /* 'S' = Standard (CCIR Rec. 584), 'I' = Inverted */ uint8_t msglen = 0, uint8_t msg[31] = {0}, uint64_t addr = 0) @@ -1578,15 +1578,15 @@ class PocsagTosendMessage : public Message { baud{baud}, type{type}, function{function}, - phase{phase}, + polarity{polarity}, msglen{msglen}, addr{addr} { memcpy(this->msg, msg, 31); } uint16_t baud = 1200; uint8_t type = 2; - char function = 'D'; - char phase = 'N'; + char function = 'A'; + char polarity = 'S'; /* 'S' = Standard, 'I' = Inverted */ uint8_t msglen = 0; uint8_t msg[31] = {0}; uint64_t addr = 0; diff --git a/firmware/common/pocsag.cpp b/firmware/common/pocsag.cpp index 4e2d860f8..a2ea2dbdc 100644 --- a/firmware/common/pocsag.cpp +++ b/firmware/common/pocsag.cpp @@ -39,8 +39,6 @@ std::string bitrate_str(BitRate bitrate) { return "1200bps"; case BitRate::FSK2400: return "2400bps"; - case BitRate::FSK3200: - return "3200bps"; default: return "????"; } @@ -352,30 +350,225 @@ int EccContainer::error_correct(uint32_t& val) { return errl; } +// ---------------------------------------------------------------------------- +// Numeric character table: 4-bit BCD -> ASCII +// ---------------------------------------------------------------------------- +static const char numeric_chars[16] = { + '0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', 'R', 'U', ' ', '-', ']', '['}; + +// Extract and bit-reverse a 4-bit nibble from a message codeword. +// POCSAG numeric digits are transmitted LSB first, so bit 30 (first transmitted) +// is the LSB of the digit value, not the MSB. +static uint8_t decode_nibble(uint32_t codeword, int nibble_idx) { + int bit_pos = 30 - nibble_idx * 4; + // bit_pos is the first transmitted bit (LSB of digit) + // bit_pos-3 is the last transmitted bit (MSB of digit) + uint8_t n = 0; + n |= ((codeword >> (bit_pos - 3)) & 1) << 3; // MSB of digit + n |= ((codeword >> (bit_pos - 2)) & 1) << 2; + n |= ((codeword >> (bit_pos - 1)) & 1) << 1; + n |= ((codeword >> (bit_pos - 0)) & 1) << 0; // LSB of digit + return n; +} + +// ---------------------------------------------------------------------------- +// Heuristic message type detection (first batch only) +// ---------------------------------------------------------------------------- + +// Count trailing fill characters in alpha buffer (NULL or space). +static int count_alpha_fill(const std::string& data) { + if (data.empty()) return 0; + int fill = 0; + for (int i = data.size() - 1; i >= 0; --i) { + char c = data[i]; + if (c == '\0' || c == ' ') + fill++; + else + break; + } + return fill; +} + +// Count trailing fill nibbles (0xC = space) in numeric buffer. +static int count_numeric_fill(const uint8_t* nibbles, int count) { + int fill = 0; + for (int i = count - 1; i >= 0; --i) { + if (nibbles[i] == 0x0C) + fill++; + else + break; + } + return fill; +} + +// Score alpha interpretation: +3 alphanumeric/space, -2 other printable, -5 control. +static int score_alpha(const std::string& data, int fill) { + int score = 0; + int content = 0; + int len = data.size(); + + for (int i = 0; i < len; ++i) { + unsigned char c = data[i]; + + // Skip trailing fill + if (i >= len - fill && (c == 0 || c == ' ')) + continue; + if (c == 0) + continue; + + content++; + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == ' ') + score += 3; + else if (c >= 0x20 && c <= 0x7E) + score -= 2; + else if (c == '\n' || c == '\r' || c == '\t' || c == 0x04) + score += 0; + else + score -= 5; + } + + if (content > 0) + score += fill * fill * 3 + fill * 5; + + return score; +} + +// Score numeric interpretation on raw nibbles. +static int score_numeric(const uint8_t* nibbles, int count, int fill) { + int raw_score = 0; + int scored = 0; + int digits = 0; + int u_count = 0; + + // Pre-scan for U nibbles + for (int i = 0; i < count; ++i) { + if (nibbles[i] == 0x0C && i >= count - fill) + continue; + if (nibbles[i] == 0x0B) + u_count++; + } + + bool urgent_prefix = (u_count == 1); + + for (int i = 0; i < count; ++i) { + uint8_t n = nibbles[i]; + if (n == 0x0C && i >= count - fill) + continue; + + scored++; + if (n <= 0x09) { + raw_score += 3; + digits++; + } else if (n == 0x0B) { + raw_score += urgent_prefix ? -1 : -15; + } else if (n == 0x0A) { + raw_score -= 5; + } else { + raw_score -= 2; + } + } + + int score = scored > 0 ? raw_score * 4 / 7 : 0; + + // Phone numbers have at most ~15 digits + if (digits > 15) + score -= (digits - 15) * 5; + + // Fill bonus (weaker than alpha: 1/16 vs 1/128 coincidence rate) + score += fill * fill; + + return score; +} + +DetectedType detect_message_type(const std::string& alpha, + const uint8_t* nibbles, + uint8_t nibble_count, + uint8_t msg_codewords) { + if (alpha.empty() && nibble_count == 0) + return DET_TONE; + + // Long messages can't be numeric (phone numbers are short) + if (msg_codewords >= 8) + return DET_ALPHA; + + int alpha_fill = count_alpha_fill(alpha); + int numeric_fill = count_numeric_fill(nibbles, nibble_count); + + int sa = score_alpha(alpha, alpha_fill) + 2; // Alpha prior bias + int sn = score_numeric(nibbles, nibble_count, numeric_fill); + + // Short message boost for alpha (1-2 data codewords) + if (msg_codewords <= 3) + sa += 3; + + return (sn > sa) ? DET_NUMERIC : DET_ALPHA; +} + +// ---------------------------------------------------------------------------- +// Batch decoder +// ---------------------------------------------------------------------------- + bool pocsag_decode_batch(const POCSAGPacket& batch, POCSAGState& state) { constexpr uint8_t codeword_max = 16; state.output.clear(); + /* Only reset numeric accumulator when starting a new message, + * not on continuation batches — numeric_buf must be cumulative + * across multi-batch messages. */ + const bool continuing_numeric = + (state.mode != STATE_HAVE_ADDRESS) && + (state.out_type == MESSAGE) && + state.type_decided && + (state.detected == DET_NUMERIC); + if (!continuing_numeric) + state.numeric_len = 0; + + /* Preserve new_message across batch boundary when STATE_HAVE_ADDRESS + * persists — the address was at the end of the previous batch and + * we haven't displayed it yet. Otherwise reset for this batch. */ + if (state.mode != STATE_HAVE_ADDRESS) + state.new_message = false; + + // Temporary nibble buffer for first-batch numeric decode. + uint8_t nibbles[max_batch_nibbles]; + uint8_t nibble_count = 0; + uint8_t msg_codewords = 0; + // Also build raw alpha for heuristic (before non-printable replacement). + std::string raw_alpha{}; + while (state.codeword_index < codeword_max) { auto codeword = batch[state.codeword_index]; bool is_address = (codeword & 0x80000000U) == 0; - // Error correct twice. First time to fix any errors it can, - // second time to count number of errors that couldn't be fixed. - state.ecc->error_correct(codeword); + // Single ECC call: fix errors and get error count. auto error_count = state.ecc->error_correct(codeword); switch (state.mode) { case STATE_CLEAR: if (is_address && codeword != POCSAG_IDLEWORD) { state.function = (codeword >> 11) & 3; - state.address = (codeword >> 10) & 0x1FFFF8U; // 18 MSBs are transmitted + state.address = (codeword >> 10) & 0x1FFFF8U; + /* Frame number = lower 3 bits of RIC, derived from the + * address codeword's position in the batch (not the + * message codeword's position). codeword_index 0-15 + * maps to frames 0-7 via index >> 1. */ + state.address |= (state.codeword_index >> 1); state.mode = STATE_HAVE_ADDRESS; state.out_type = ADDRESS; state.errors = error_count; + state.new_message = true; + state.type_decided = false; + state.detected = DET_UNKNOWN; state.ascii_idx = 0; state.ascii_data = 0; + state.prev_cw_err = 0; + state.cur_cw_err = 0; + nibble_count = 0; + msg_codewords = 0; + raw_alpha.clear(); } else if (codeword == POCSAG_IDLEWORD) { state.out_type = IDLE; } @@ -383,54 +576,134 @@ bool pocsag_decode_batch(const POCSAGPacket& batch, POCSAGState& state) { case STATE_HAVE_ADDRESS: if (is_address) { - // Got another address, return the current state. + // Got another address. Run heuristic before returning if we have pending data. + if (!state.type_decided && msg_codewords > 0) { + state.detected = detect_message_type(raw_alpha, nibbles, nibble_count, msg_codewords); + state.type_decided = true; + state.msg_codewords = msg_codewords; + if (state.detected == DET_NUMERIC) { + state.numeric_len = 0; + for (uint8_t ni = 0; ni < nibble_count && state.numeric_len < sizeof(state.numeric_buf); ++ni) + state.numeric_buf[state.numeric_len++] = numeric_chars[nibbles[ni] & 0x0F]; + } + } state.mode = STATE_CLEAR; return true; } - // First message codeword, complete the address. - state.address |= (state.codeword_index >> 1); // Add in the 3 LSBs (frame #). + /* Frame number already applied in STATE_CLEAR. + * Transition to message decoding. */ state.mode = STATE_GETTING_MSG; [[fallthrough]]; case STATE_GETTING_MSG: if (is_address) { - // Codeword isn't a message, return the current state. + // Message ended. Run heuristic before returning. + if (!state.type_decided && msg_codewords > 0) { + state.detected = detect_message_type(raw_alpha, nibbles, nibble_count, msg_codewords); + state.type_decided = true; + state.msg_codewords = msg_codewords; + if (state.detected == DET_NUMERIC) { + state.numeric_len = 0; + for (uint8_t ni = 0; ni < nibble_count && state.numeric_len < sizeof(state.numeric_buf); ++ni) + state.numeric_buf[state.numeric_len++] = numeric_chars[nibbles[ni] & 0x0F]; + } + } state.mode = STATE_CLEAR; return true; } state.out_type = MESSAGE; state.errors += error_count; - state.ascii_data |= (codeword >> 11) & 0xFFFFF; // Get 20 message bits. + msg_codewords++; + + // Track per-codeword error level for character coloring. + // 0=clean, 1-2=corrected, 3=uncorrectable. + state.prev_cw_err = state.cur_cw_err; + state.cur_cw_err = (error_count >= 3) ? 3 : error_count; + + // --- Alpha decode (always) --- + // Bits remaining from previous codeword inherit prev_cw_err. + // New 20 bits from this codeword use cur_cw_err. + // Characters spanning boundary get the worst of both. + uint32_t bits_from_prev = state.ascii_idx; // leftover bits before adding new ones + + state.ascii_data |= ((uint64_t)((codeword >> 11) & 0xFFFFF)) << (44 - state.ascii_idx); state.ascii_idx += 20; - // Raw 20 bits to 7 bit reversed ASCII. - // NB: This is processed MSB first, any remaining bits are shifted - // up so a whole 7 bits are processed with the next codeword. while (state.ascii_idx >= 7) { + // Determine error level for this character. + // If some bits came from previous codeword and some from current, + // use the worst of both error levels. + uint8_t char_err; + if (bits_from_prev >= 7) { + // Entire character from previous codeword's leftover bits. + char_err = state.prev_cw_err; + bits_from_prev -= 7; + } else if (bits_from_prev > 0) { + // Character spans boundary. + char_err = std::max(state.prev_cw_err, state.cur_cw_err); + bits_from_prev = 0; + } else { + // Entirely from current codeword. + char_err = state.cur_cw_err; + } + + // Extract top 7 bits from accumulator + char ascii_char = (state.ascii_data >> 57) & 0x7F; + state.ascii_data <<= 7; state.ascii_idx -= 7; - char ascii_char = (state.ascii_data >> state.ascii_idx) & 0x7F; - // Reverse the bits. (TODO: __RBIT?) - ascii_char = (ascii_char & 0xF0) >> 4 | (ascii_char & 0x0F) << 4; // 01234567 -> 45670123 - ascii_char = (ascii_char & 0xCC) >> 2 | (ascii_char & 0x33) << 2; // 45670123 -> 67452301 - ascii_char = (ascii_char & 0xAA) >> 2 | (ascii_char & 0x55); // 67452301 -> 76543210 + // Reverse bits (LSB-first encoding) + ascii_char = (ascii_char & 0xF0) >> 4 | (ascii_char & 0x0F) << 4; + ascii_char = (ascii_char & 0xCC) >> 2 | (ascii_char & 0x33) << 2; + ascii_char = (ascii_char & 0xAA) >> 2 | (ascii_char & 0x55); - // Translate non-printable chars. TODO: Leave CRLF? + // Store raw char for heuristic + if (!state.type_decided) + raw_alpha += ascii_char; + + // Translate non-printable chars. if (ascii_char < 32 || ascii_char > 126) state.output += "."; else state.output += ascii_char; } - state.ascii_data <<= 20; // Remaining bits are for next iteration... + // --- Numeric decode --- + // First batch: accumulate nibbles locally for heuristic scoring. + // Continuation batches: if already decided numeric, decode directly + // into numeric_buf for the app layer. + if (!state.type_decided && nibble_count + 5 <= max_batch_nibbles) { + for (int n = 0; n < 5; ++n) { + nibbles[nibble_count++] = decode_nibble(codeword, n); + } + } else if (state.type_decided && state.detected == DET_NUMERIC && + state.numeric_len + 5 <= (uint8_t)sizeof(state.numeric_buf)) { + for (int n = 0; n < 5; ++n) { + uint8_t nib = decode_nibble(codeword, n); + state.numeric_buf[state.numeric_len++] = numeric_chars[nib & 0x0F]; + } + } + break; } state.codeword_index++; } + // End of batch. If we have message data and type not yet decided, run heuristic. + if (state.out_type == MESSAGE && !state.type_decided && msg_codewords > 0) { + state.detected = detect_message_type(raw_alpha, nibbles, nibble_count, msg_codewords); + state.type_decided = true; + state.msg_codewords = msg_codewords; + if (state.detected == DET_NUMERIC) { + state.numeric_len = 0; + for (uint8_t ni = 0; ni < nibble_count && state.numeric_len < sizeof(state.numeric_buf); ++ni) + state.numeric_buf[state.numeric_len++] = numeric_chars[nibbles[ni] & 0x0F]; + } + } + return false; } diff --git a/firmware/common/pocsag.hpp b/firmware/common/pocsag.hpp index 583fd0158..1844a00f1 100644 --- a/firmware/common/pocsag.hpp +++ b/firmware/common/pocsag.hpp @@ -77,6 +77,17 @@ class EccContainer { uint32_t bch[1025]; }; +/* Detected message type from heuristic scoring. */ +enum DetectedType : uint8_t { + DET_UNKNOWN, + DET_TONE, + DET_ALPHA, + DET_NUMERIC +}; + +/* Max numeric nibbles per batch: 16 codewords * 5 nibbles. */ +constexpr uint8_t max_batch_nibbles = 80; + struct POCSAGState { EccContainer* ecc = nullptr; uint8_t codeword_index = 0; @@ -84,17 +95,28 @@ struct POCSAGState { uint32_t address = 0; Mode mode = STATE_CLEAR; OutputType out_type = EMPTY; - uint32_t ascii_data = 0; + uint64_t ascii_data = 0; // Was uint32_t — fixed overflow for long messages. uint32_t ascii_idx = 0; uint32_t errors = 0; - std::string output{}; + uint8_t prev_cw_err = 0; // Error level of previous codeword (for chars spanning boundary). + uint8_t cur_cw_err = 0; // Error level of current codeword being decoded. + bool new_message = false; // True when decoder starts a new address. + bool type_decided = false; // True after first-batch heuristic runs. + DetectedType detected = DET_UNKNOWN; + uint8_t msg_codewords = 0; // Message codewords in current batch (for heuristic). + std::string output{}; // Alpha decode (always populated, with color escapes). + // Numeric output is built into a separate local in pocsag_decode_batch + // and stored here only when heuristic detects numeric. + // Using a fixed buffer to avoid std::string overhead in external apps + // that embed POCSAGState (e.g. battleship). + char numeric_buf[80]{}; + uint8_t numeric_len = 0; }; -const pocsag::BitRate pocsag_bitrates[4] = { +const pocsag::BitRate pocsag_bitrates[3] = { pocsag::BitRate::FSK512, pocsag::BitRate::FSK1200, pocsag::BitRate::FSK2400, - pocsag::BitRate::FSK3200, }; std::string bitrate_str(BitRate bitrate); @@ -107,6 +129,10 @@ void pocsag_encode(const MessageType type, BCHCode& BCH_code, const uint32_t fun // Returns true if the batch has more to process. bool pocsag_decode_batch(const POCSAGPacket& batch, POCSAGState& state); +// Heuristic: detect whether message content is numeric or alpha. +// Called once after first batch of a new message. +DetectedType detect_message_type(const std::string& alpha, const uint8_t* nibbles, uint8_t nibble_count, uint8_t msg_codewords); + } /* namespace pocsag */ #endif /*__POCSAG_H__*/ diff --git a/firmware/common/pocsag_packet.hpp b/firmware/common/pocsag_packet.hpp index 230a3b4ef..c03ba7a00 100644 --- a/firmware/common/pocsag_packet.hpp +++ b/firmware/common/pocsag_packet.hpp @@ -35,8 +35,7 @@ enum BitRate : uint32_t { UNKNOWN, FSK512 = 512, FSK1200 = 1200, - FSK2400 = 2400, - FSK3200 = 3200 + FSK2400 = 2400 }; enum PacketFlag : uint32_t { @@ -88,15 +87,25 @@ class POCSAGPacket { return flag_; } + void set_inverted(bool inverted) { + inverted_ = inverted; + } + + bool inverted() const { + return inverted_; + } + void clear() { codewords.fill(0); bitrate_ = 0u; flag_ = NORMAL; + inverted_ = false; } private: uint16_t bitrate_{0}; PacketFlag flag_{NORMAL}; + bool inverted_{false}; batch_t codewords{}; Timestamp timestamp_{}; };