From cd7611f3bfed2e61e6c547cf0db8ad9145b3ba1e Mon Sep 17 00:00:00 2001 From: Jon Beniston Date: Sat, 1 Aug 2026 20:08:24 +0100 Subject: [PATCH] Automatically detect baud rate. Report in table. Use matched filter and Gardner timing loop to improve decoder performance. Fix bug where last message of a transmission is lost. --- plugins/channelrx/demodpager/pagerdemod.cpp | 12 +- plugins/channelrx/demodpager/pagerdemod.h | 9 +- .../channelrx/demodpager/pagerdemodgui.cpp | 30 +- plugins/channelrx/demodpager/pagerdemodgui.h | 3 +- plugins/channelrx/demodpager/pagerdemodgui.ui | 46 +- .../demodpager/pagerdemodsettings.cpp | 9 - .../channelrx/demodpager/pagerdemodsettings.h | 6 +- .../channelrx/demodpager/pagerdemodsink.cpp | 493 +++++++++++------- plugins/channelrx/demodpager/pagerdemodsink.h | 157 +++++- plugins/channelrx/demodpager/readme.md | 33 +- .../api/swagger/include/PagerDemod.yaml | 3 - 11 files changed, 515 insertions(+), 286 deletions(-) diff --git a/plugins/channelrx/demodpager/pagerdemod.cpp b/plugins/channelrx/demodpager/pagerdemod.cpp index 739d325db..b0a682642 100644 --- a/plugins/channelrx/demodpager/pagerdemod.cpp +++ b/plugins/channelrx/demodpager/pagerdemod.cpp @@ -205,7 +205,8 @@ bool PagerDemod::handleMessage(const Message& cmd) << CSV::escape(report.getAlphaMessage()) << "," << report.getNumericMessage() << "," << QString::number(report.getEvenParityErrors()) << "," - << QString::number(report.getBCHParityErrors()) << "\n"; + << QString::number(report.getBCHParityErrors()) << "," + << QString::number(report.getBaud()) << "\n"; m_logStream.flush(); } @@ -292,7 +293,7 @@ void PagerDemod::applySettings(const QStringList& settingsKeys, const PagerDemod if (newFile) { // Write header - m_logStream << "Date,Time,Address,Function Bits,Alpha,Numeric,Even Parity Errors,BCH Parity Errors\n"; + m_logStream << "Date,Time,Address,Function Bits,Alpha,Numeric,Even Parity Errors,BCH Parity Errors,Baud\n"; } } else @@ -411,9 +412,6 @@ void PagerDemod::webapiUpdateChannelSettings( const QStringList& channelSettingsKeys, SWGSDRangel::SWGChannelSettings& response) { - if (channelSettingsKeys.contains("baud")) { - settings.m_baud = response.getPagerDemodSettings()->getBaud(); - } if (channelSettingsKeys.contains("decode")) { settings.m_decode = (PagerDemodSettings::Decode) response.getPagerDemodSettings()->getDecode(); } @@ -481,7 +479,6 @@ void PagerDemod::webapiUpdateChannelSettings( void PagerDemod::webapiFormatChannelSettings(SWGSDRangel::SWGChannelSettings& response, const PagerDemodSettings& settings) { - response.getPagerDemodSettings()->setBaud(settings.m_baud); response.getPagerDemodSettings()->setDecode((int) settings.m_decode); response.getPagerDemodSettings()->setReverse(settings.m_reverse ? 1 : 0); response.getPagerDemodSettings()->setInputFrequencyOffset(settings.m_inputFrequencyOffset); @@ -607,9 +604,6 @@ void PagerDemod::webapiFormatChannelSettings( // transfer data that has been modified. When force is on transfer all data except reverse API data - if (channelSettingsKeys.contains("baud") || force) { - swgPagerDemodSettings->setBaud(settings.m_baud); - } if (channelSettingsKeys.contains("decode") || force) { swgPagerDemodSettings->setDecode((int) settings.m_decode); } diff --git a/plugins/channelrx/demodpager/pagerdemod.h b/plugins/channelrx/demodpager/pagerdemod.h index 9e005bf06..0f55c4cc2 100644 --- a/plugins/channelrx/demodpager/pagerdemod.h +++ b/plugins/channelrx/demodpager/pagerdemod.h @@ -82,6 +82,7 @@ public: QString getNumericMessage() const { return m_numericMessage; } int getEvenParityErrors() const { return m_evenParityErrors; } int getBCHParityErrors() const { return m_bchParityErrors; } + int getBaud() const { return m_baud; } QDateTime getDateTime() const { return m_dateTime; } static MsgPagerMessage* create( @@ -90,7 +91,8 @@ public: const QString& alphaMessage, const QString& numericMessage, int evenParityErrors, - int bchParityErrors + int bchParityErrors, + int baud ) { return new MsgPagerMessage( @@ -100,6 +102,7 @@ public: numericMessage, evenParityErrors, bchParityErrors, + baud, QDateTime::currentDateTime() ); } @@ -111,9 +114,10 @@ public: QString m_numericMessage; int m_evenParityErrors; int m_bchParityErrors; + int m_baud; QDateTime m_dateTime; - MsgPagerMessage(int address, int functionBits, const QString& alphaMessage, const QString& numericMessage, int evenParityErrors, int bchParityErrors, QDateTime dateTime) : + MsgPagerMessage(int address, int functionBits, const QString& alphaMessage, const QString& numericMessage, int evenParityErrors, int bchParityErrors, int baud, QDateTime dateTime) : Message(), m_address(address), m_functionBits(functionBits), @@ -121,6 +125,7 @@ public: m_numericMessage(numericMessage), m_evenParityErrors(evenParityErrors), m_bchParityErrors(bchParityErrors), + m_baud(baud), m_dateTime(dateTime) { } diff --git a/plugins/channelrx/demodpager/pagerdemodgui.cpp b/plugins/channelrx/demodpager/pagerdemodgui.cpp index 6117a854d..a76582cfc 100644 --- a/plugins/channelrx/demodpager/pagerdemodgui.cpp +++ b/plugins/channelrx/demodpager/pagerdemodgui.cpp @@ -64,6 +64,7 @@ void PagerDemodGUI::resizeTable() ui->messages->setItem(row, PagerDemodSettings::MESSAGE_COL_NUMERIC, new QTableWidgetItem("123456789123456789123456789123456789123456789123456789")); ui->messages->setItem(row, PagerDemodSettings::MESSAGE_COL_EVEN_PE, new QTableWidgetItem("0")); ui->messages->setItem(row, PagerDemodSettings::MESSAGE_COL_BCH_PE, new QTableWidgetItem("0")); + ui->messages->setItem(row, PagerDemodSettings::MESSAGE_COL_BAUD, new QTableWidgetItem("2400-")); ui->messages->resizeColumnsToContents(); ui->messages->removeRow(row); } @@ -220,7 +221,7 @@ QString PagerDemodGUI::selectMessage(int functionBits, const QString &numericMes } // Add row to table -void PagerDemodGUI::messageReceived(const QDateTime dateTime, int address, int functionBits, +void PagerDemodGUI::messageReceived(const QDateTime dateTime, int address, int functionBits, int baud, const QString &numericMessage, const QString &alphaMessage, int evenParityErrors, int bchParityErrors) { @@ -280,6 +281,7 @@ void PagerDemodGUI::messageReceived(const QDateTime dateTime, int address, int f QTableWidgetItem *numericItem = new QTableWidgetItem(); QTableWidgetItem *evenPEItem = new QTableWidgetItem(); QTableWidgetItem *bchPEItem = new QTableWidgetItem(); + QTableWidgetItem *baudItem = new QTableWidgetItem(); ui->messages->setItem(row, PagerDemodSettings::MESSAGE_COL_DATE, dateItem); ui->messages->setItem(row, PagerDemodSettings::MESSAGE_COL_TIME, timeItem); ui->messages->setItem(row, PagerDemodSettings::MESSAGE_COL_ADDRESS, addressItem); @@ -289,6 +291,7 @@ void PagerDemodGUI::messageReceived(const QDateTime dateTime, int address, int f ui->messages->setItem(row, PagerDemodSettings::MESSAGE_COL_NUMERIC, numericItem); ui->messages->setItem(row, PagerDemodSettings::MESSAGE_COL_EVEN_PE, evenPEItem); ui->messages->setItem(row, PagerDemodSettings::MESSAGE_COL_BCH_PE, bchPEItem); + ui->messages->setItem(row, PagerDemodSettings::MESSAGE_COL_BAUD, baudItem); dateItem->setText(dateTime.date().toString()); timeItem->setText(dateTime.time().toString()); addressItem->setText(addressString); @@ -298,6 +301,7 @@ void PagerDemodGUI::messageReceived(const QDateTime dateTime, int address, int f numericItem->setText(numericMessage); evenPEItem->setText(QString("%1").arg(evenParityErrors)); bchPEItem->setText(QString("%1").arg(bchParityErrors)); + baudItem->setData(Qt::DisplayRole, baud); if (!m_loadingData) { filterRow(row); @@ -326,7 +330,7 @@ bool PagerDemodGUI::handleMessage(const Message& message) else if (PagerDemod::MsgPagerMessage::match(message)) { const PagerDemod::MsgPagerMessage& report = (const PagerDemod::MsgPagerMessage&) message; - messageReceived(report.getDateTime(), report.getAddress(), report.getFunctionBits(), + messageReceived(report.getDateTime(), report.getAddress(), report.getFunctionBits(), report.getBaud(), report.getNumericMessage(), report.getAlphaMessage(), report.getEvenParityErrors(), report.getBCHParityErrors()); return true; @@ -394,13 +398,6 @@ void PagerDemodGUI::on_fmDev_valueChanged(int value) applySettings(QStringList("fmDeviation")); } -void PagerDemodGUI::on_baud_currentIndexChanged(int index) -{ - (void)index; - m_settings.m_baud = ui->baud->currentText().toInt(); - applySettings(QStringList("baud")); -} - void PagerDemodGUI::on_decode_currentIndexChanged(int index) { m_settings.m_decode = (PagerDemodSettings::Decode)index; @@ -677,13 +674,6 @@ void PagerDemodGUI::displaySettings() ui->deltaFrequency->setValue(m_channelMarker.getCenterFrequency()); - if (m_settings.m_baud == 512) { - ui->baud->setCurrentIndex(0); - } else if (m_settings.m_baud == 1200) { - ui->baud->setCurrentIndex(1); - } else { - ui->baud->setCurrentIndex(2); - } ui->decode->setCurrentIndex((int)m_settings.m_decode); ui->rfBWText->setText(QString("%1k").arg(m_settings.m_rfBandwidth / 1000.0, 0, 'f', 1)); @@ -850,6 +840,7 @@ void PagerDemodGUI::on_logOpen_clicked() int numericCol = colIndexes.value("Numeric"); int evenCol = colIndexes.value("Even Parity Errors"); int bchCol = colIndexes.value("BCH Parity Errors"); + int baudCol = colIndexes.value("Baud", -1); int maxCol = std::max({dateCol, timeCol, addressCol, functionCol, alphaCol, numericCol, evenCol, bchCol}); QMessageBox dialog(this); @@ -879,7 +870,11 @@ void PagerDemodGUI::on_logOpen_clicked() int evenErrors = cols[evenCol].toInt(); int bchErrors = cols[bchCol].toInt(); - messageReceived(dateTime, address, functionBits, + // Baud is absent from logs written before it was recorded + int baud = ((baudCol >= 0) && (baudCol < cols.size())) + ? cols[baudCol].toInt() : 0; + + messageReceived(dateTime, address, functionBits, baud, cols[numericCol], cols[alphaCol], evenErrors, bchErrors); @@ -918,7 +913,6 @@ void PagerDemodGUI::makeUIConnections() QObject::connect(ui->deltaFrequency, &ValueDialZ::changed, this, &PagerDemodGUI::on_deltaFrequency_changed); QObject::connect(ui->rfBW, &QSlider::valueChanged, this, &PagerDemodGUI::on_rfBW_valueChanged); QObject::connect(ui->fmDev, &QSlider::valueChanged, this, &PagerDemodGUI::on_fmDev_valueChanged); - QObject::connect(ui->baud, QOverload::of(&QComboBox::currentIndexChanged), this, &PagerDemodGUI::on_baud_currentIndexChanged); QObject::connect(ui->decode, QOverload::of(&QComboBox::currentIndexChanged), this, &PagerDemodGUI::on_decode_currentIndexChanged); QObject::connect(ui->charset, &QToolButton::clicked, this, &PagerDemodGUI::on_charset_clicked); QObject::connect(ui->filterAddress, &QLineEdit::editingFinished, this, &PagerDemodGUI::on_filterAddress_editingFinished); diff --git a/plugins/channelrx/demodpager/pagerdemodgui.h b/plugins/channelrx/demodpager/pagerdemodgui.h index 874431ca4..827943bc8 100644 --- a/plugins/channelrx/demodpager/pagerdemodgui.h +++ b/plugins/channelrx/demodpager/pagerdemodgui.h @@ -107,7 +107,7 @@ private: void applySettings(const QStringList& settingsKeys, bool force = false); void displaySettings(); QString selectMessage(int functionBits, const QString &numericMessage, const QString &alphaMessage) const; - void messageReceived(const QDateTime dateTime, int address, int functionBits, + void messageReceived(const QDateTime dateTime, int address, int functionBits, int baud, const QString &numericMessage, const QString &alphaMessage, int evenParityErrors, int bchParityErrors); bool handleMessage(const Message& message); @@ -132,7 +132,6 @@ private slots: void on_deltaFrequency_changed(qint64 value); void on_rfBW_valueChanged(int index); void on_fmDev_valueChanged(int value); - void on_baud_currentIndexChanged(int index); void on_decode_currentIndexChanged(int index); void on_charset_clicked(); void on_filterAddress_editingFinished(); diff --git a/plugins/channelrx/demodpager/pagerdemodgui.ui b/plugins/channelrx/demodpager/pagerdemodgui.ui index a1c13e259..61c3296e4 100644 --- a/plugins/channelrx/demodpager/pagerdemodgui.ui +++ b/plugins/channelrx/demodpager/pagerdemodgui.ui @@ -372,44 +372,6 @@ - - - - Baud - - - - - - - - 60 - 0 - - - - Baud rate - - - 1 - - - - 512 - - - - - 1200 - - - - - 2400 - - - - @@ -851,6 +813,14 @@ Number of BCH parity errors detected in message + + + Baud + + + Baud rate the message was received at, detected from the preamble + + diff --git a/plugins/channelrx/demodpager/pagerdemodsettings.cpp b/plugins/channelrx/demodpager/pagerdemodsettings.cpp index 74fdf1924..8c18fa5c3 100644 --- a/plugins/channelrx/demodpager/pagerdemodsettings.cpp +++ b/plugins/channelrx/demodpager/pagerdemodsettings.cpp @@ -36,7 +36,6 @@ PagerDemodSettings::PagerDemodSettings() : void PagerDemodSettings::resetToDefaults() { - m_baud = 1200; m_inputFrequencyOffset = 0; m_rfBandwidth = 20000.0f; m_fmDeviation = 4500.0f; @@ -81,7 +80,6 @@ QByteArray PagerDemodSettings::serialize() const s.writeS32(1, m_inputFrequencyOffset); s.writeFloat(2, m_rfBandwidth); s.writeFloat(3, m_fmDeviation); - s.writeS32(4, m_baud); s.writeString(5, m_filterAddress); s.writeS32(6, (int)m_decode); s.writeBool(7, m_udpEnabled); @@ -154,7 +152,6 @@ bool PagerDemodSettings::deserialize(const QByteArray& data) d.readS32(1, &m_inputFrequencyOffset, 0); d.readFloat(2, &m_rfBandwidth, 20000.0f); d.readFloat(3, &m_fmDeviation, 4500.0f); - d.readS32(4, &m_baud, 1200); d.readString(5, &m_filterAddress, ""); d.readS32(6, (int*)&m_decode, (int)Standard); d.readBool(7, &m_udpEnabled); @@ -244,9 +241,6 @@ bool PagerDemodSettings::deserialize(const QByteArray& data) void PagerDemodSettings::applySettings(const QStringList& settingsKeys, const PagerDemodSettings& settings) { - if (settingsKeys.contains("baud")) { - m_baud = settings.m_baud; - } if (settingsKeys.contains("inputFrequencyOffset")) { m_inputFrequencyOffset = settings.m_inputFrequencyOffset; } @@ -353,9 +347,6 @@ QString PagerDemodSettings::getDebugString(const QStringList& settingsKeys, bool { std::ostringstream ostr; - if (settingsKeys.contains("baud") || force) { - ostr << " m_baud: " << m_baud; - } if (settingsKeys.contains("inputFrequencyOffset") || force) { ostr << " m_inputFrequencyOffset: " << m_inputFrequencyOffset; } diff --git a/plugins/channelrx/demodpager/pagerdemodsettings.h b/plugins/channelrx/demodpager/pagerdemodsettings.h index ba630d6d7..acb10b9b0 100644 --- a/plugins/channelrx/demodpager/pagerdemodsettings.h +++ b/plugins/channelrx/demodpager/pagerdemodsettings.h @@ -33,7 +33,7 @@ class QDataStream; class Serializable; // Number of columns in the tables -#define PAGERDEMOD_MESSAGE_COLUMNS 9 +#define PAGERDEMOD_MESSAGE_COLUMNS 10 struct PagerDemodSettings { @@ -46,7 +46,8 @@ struct PagerDemodSettings MESSAGE_COL_ALPHA, MESSAGE_COL_NUMERIC, MESSAGE_COL_EVEN_PE, - MESSAGE_COL_BCH_PE + MESSAGE_COL_BCH_PE, + MESSAGE_COL_BAUD }; struct NotificationSettings { @@ -66,7 +67,6 @@ struct PagerDemodSettings bool deserialize(const QByteArray& data); }; - qint32 m_baud; //!< 512, 1200 or 2400 qint32 m_inputFrequencyOffset; Real m_rfBandwidth; Real m_fmDeviation; // // // Copyright (C) 2021-2022 Edouard Griffiths, F4EXB // +// Some code by AI // // // // 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 // @@ -39,13 +40,11 @@ PagerDemodSink::PagerDemodSink() : m_magsqCount(0), m_messageQueueToChannel(nullptr), m_dcOffset(0.0f), - m_dataPrev(0), m_inverted(false), m_bit(0), m_gotSOP(false), m_bits(0), m_bitCount(0), - m_syncCount(75), m_batchNumber(0), m_wordCount(0), m_addressValid(false), @@ -55,6 +54,14 @@ PagerDemodSink::PagerDemodSink() : m_bchErrors(0), m_alphaBitBuffer(0), m_alphaBitBufferBits(0), + m_baud(0), + m_mfSum(0.0f), + m_mfPtr(0), + m_timing(0.0f), + m_yPrev(0.0f), + m_yMid(0.0f), + m_gotMid(false), + m_yPower(0.0f), m_sampleBufferIndex(0) { m_magsq = 0.0; @@ -272,33 +279,28 @@ void PagerDemodSink::decodeBatch() { for (int word = 0; word < PAGERDEMOD_CODEWORDS_PER_FRAME; word++) { + // If BCH decoding failed, we can't trust any of the bits in this codeword, + // including the address/message flag in the MSB. Treat it as an erasure, so it + // can't start a phantom address or prematurely terminate a valid message. + // The 20 message bits are still fed into the alpha bit buffer below, to keep + // the 7-bit character boundaries aligned - m_bchErrors flags the message as + // containing errors + if (m_codeWordsBCHError[i]) + { + if (m_addressValid) + { + m_bchErrors++; + addMessageBits((m_codeWords[i] >> 11) & 0xfffff); + } + i++; + continue; + } + bool addressCodeWord = ((m_codeWords[i] >> 31) & 1) == 0; // Stop decoding current message if we receive a new address - if (addressCodeWord && m_addressValid) - { - m_numericMessage = m_numericMessage.trimmed(); // Remove trailing spaces - if (getMessageQueueToChannel()) - { - // Convert from 7-bit to UTF-8 using user specified encoding - for (int i = 0; i < m_alphaMessage.size(); i++) - { - QChar c = m_alphaMessage[i]; - int idx = m_settings.m_sevenbit.indexOf(c.toLatin1()); - if (idx >= 0) { - c = QChar(m_settings.m_unicode[idx]); - } - m_alphaMessage[i] = c; - } - // Reverse reading order, if required - if (m_settings.m_reverse) { - std::reverse(m_alphaMessage.begin(), m_alphaMessage.end()); - } - // Send to channel and GUI - PagerDemod::MsgPagerMessage *msg = PagerDemod::MsgPagerMessage::create(m_address, m_functionBits, m_alphaMessage, m_numericMessage, m_parityErrors, m_bchErrors); - getMessageQueueToChannel()->push(msg); - } - m_addressValid = false; + if (addressCodeWord && m_addressValid) { + sendMessage(); } // Check parity bit @@ -321,7 +323,7 @@ void PagerDemodSink::decodeBatch() m_alphaBitBufferBits = 0; m_alphaBitBuffer = 0; m_parityErrors = parityError ? 1 : 0; - m_bchErrors = m_codeWordsBCHError[i] ? 1 : 0; + m_bchErrors = 0; // Erased codewords never get here, so this one decoded cleanly m_addressValid = true; } else if (m_addressValid) @@ -329,48 +331,11 @@ void PagerDemodSink::decodeBatch() // Message - decode as both numeric and ASCII - not all operators use functionBits to indidcate encoding // Only decoded after an address codeword, otherwise we'd be decoding a message we've // tuned in to the middle of, without knowing who it's for - int messageBits = (m_codeWords[i] >> 11) & 0xfffff; if (parityError) { m_parityErrors++; } - if (m_codeWordsBCHError[i]) { - m_bchErrors++; - } - // Numeric format - for (int j = 16; j >= 0; j -= 4) - { - quint32 numericBits = (messageBits >> j) & 0xf; - numericBits = reverse(numericBits) >> (32-4); - // Spec has 0xa as 'spare', but other decoders treat is as . - const char numericChars[] = { - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', 'U', ' ', '-', ')', '(' - }; - char numericChar = numericChars[numericBits]; - m_numericMessage.append(numericChar); - } - - // 7-bit ASCII alpnanumeric format - m_alphaBitBuffer = (m_alphaBitBuffer << 20) | messageBits; - m_alphaBitBufferBits += 20; - while (m_alphaBitBufferBits >= 7) - { - // Extract next 7-bit character from bit buffer - char c = (m_alphaBitBuffer >> (m_alphaBitBufferBits-7)) & 0x7f; - // Reverse bit ordering - c = reverse(c) >> (32-7); - // Add to received message string (excluding, null, end of text, end ot transmission) - if (c != 0 && c != 0x3 && c != 0x4) { - m_alphaMessage.append(c); - } - // Remove from bit buffer - m_alphaBitBufferBits -= 7; - if (m_alphaBitBufferBits == 0) { - m_alphaBitBuffer = 0; - } else { - m_alphaBitBuffer &= (1 << m_alphaBitBufferBits) - 1; - } - } + addMessageBits((m_codeWords[i] >> 11) & 0xfffff); } // Move to next codeword @@ -379,6 +344,262 @@ void PagerDemodSink::decodeBatch() } } +// Retune the bit level demodulator to a different baud rate. This is only called while +// unsynced, so there is no partially received message to lose +void PagerDemodSink::setBaud(int baud) +{ + m_baud = baud; + m_samplesPerSymbol = PagerDemodSettings::m_channelSampleRate / baud; + + // Signal is a square wave - so include several harmonics + m_lowpassBaud.create(301, PagerDemodSettings::m_channelSampleRate, baud * 5.0f); + + m_bits = 0; + m_bitCount = 0; + + // Restart the matched filter and timing loop at the new symbol period + m_mfBuf.assign(m_samplesPerSymbol, 0.0f); + m_mfSum = 0.0f; + m_mfPtr = 0; + m_timing = m_samplesPerSymbol; + m_yPrev = 0.0f; + m_yMid = 0.0f; + m_gotMid = false; + m_yPower = 0.0f; + + qDebug() << "PagerDemodSink::setBaud: " << baud << " m_samplesPerSymbol: " << m_samplesPerSymbol; +} + +// Matched filter (boxcar integrate and dump over one symbol, which is the matched filter +// for NRZ) and Gardner timing recovery. +// +// The symbol clock free runs and is only nudged, rather than being reset by every signal +// edge. That matters because an isolated noise transition could previously delay or skip a +// bit, and a single inserted or deleted bit destroys codeword alignment for the rest of +// the batch, which BCH cannot recover from. +bool PagerDemodSink::matchedFilterAndDpll(Real v) +{ + // Matched filter: running sum over one symbol period + m_mfSum -= m_mfBuf[m_mfPtr]; + m_mfBuf[m_mfPtr] = v; + m_mfSum += v; + m_mfPtr = (m_mfPtr + 1) % m_samplesPerSymbol; + + m_timing -= 1.0f; + + // Sample midway between the previous and current symbol instants, for the timing error + if (!m_gotMid && (m_timing <= m_samplesPerSymbol / 2.0f)) + { + m_yMid = m_mfSum; + m_gotMid = true; + } + + if (m_timing > 0.0f) { + return false; + } + + Real y = m_mfSum; + + // Gardner timing error detector - needs no decision and is zero when we're on time + Real e = (y - m_yPrev) * m_yMid; + + // Normalise out the amplitude, so loop gain doesn't depend on signal level + m_yPower = 0.999f * m_yPower + 0.001f * (y * y); + Real eNorm = (m_yPower > 1e-9f) ? (e / m_yPower) : 0.0f; + eNorm = std::max(-1.0f, std::min(1.0f, eNorm)); + + m_timing += m_samplesPerSymbol - m_dpllGain * eNorm; + + // Keep the correction sane when the loop is being driven by noise + if (m_timing < m_samplesPerSymbol * 0.5f) { + m_timing = m_samplesPerSymbol * 0.5f; + } else if (m_timing > m_samplesPerSymbol * 1.5f) { + m_timing = m_samplesPerSymbol * 1.5f; + } + + m_yPrev = y; + m_gotMid = false; + + // According to a variety of places on the web, high frequency is a 0, low is 1. + // While this seems to be correct in the UK, some IQ files I've obtained seem + // to be reversed, so we support both. The shift register always holds bits in the + // standard mapping and polarity is applied when a codeword is extracted, so the + // retained history stays consistent if polarity changes on sync loss + int data = y >= 0.0f; + handleBit(!data); + + return true; +} + +// Accumulate a demodulated bit, looking for the frame sync code and then codewords +void PagerDemodSink::handleBit(int bit) +{ + m_bit = bit; + + // Store in shift reg. MSB transmitted first + m_bits = (m_bits << 1) | m_bit; + m_bitCount++; + + if (m_bitCount > 32) { + m_bitCount = 32; + } + + if ((m_bitCount == 32) && !m_gotSOP) + { + // Look for synccode that starts a batch - allow two errors that can be corrected + if (m_bits == PAGERDEMOD_POCSAG_SYNCCODE) + { + m_gotSOP = true; + m_inverted = false; + } + else if (m_bits == PAGERDEMOD_POCSAG_SYNCCODE_INV) + { + m_gotSOP = true; + m_inverted = true; + } + else if (popcount((m_bits ^ PAGERDEMOD_POCSAG_SYNCCODE) & 0xfffffffeU) <= 2) + { + quint32 correctedCW; + if (bchDecode(m_bits, correctedCW) + && ((correctedCW & 0xfffffffeU) == (PAGERDEMOD_POCSAG_SYNCCODE & 0xfffffffeU))) + { + m_gotSOP = true; + m_inverted = false; + } + } + else if (popcount((m_bits ^ PAGERDEMOD_POCSAG_SYNCCODE_INV) & 0xfffffffeU) <= 2) + { + quint32 correctedCW; + if (bchDecode(~m_bits, correctedCW) + && ((correctedCW & 0xfffffffeU) == (PAGERDEMOD_POCSAG_SYNCCODE & 0xfffffffeU))) + { + m_gotSOP = true; + m_inverted = true; + } + } + + if (m_gotSOP) + { + // Reset demod state + m_bits = 0; + m_bitCount = 0; + m_codeWords[0] = PAGERDEMOD_POCSAG_SYNCCODE; + m_wordCount = 1; + m_addressValid = false; + } + } + else if ((m_bitCount == 32) && m_gotSOP) + { + // Got a complete codeword - apply the detected polarity, then use BCH decoding + // to fix any bit errors + quint32 cw = m_inverted ? ~m_bits : m_bits; + quint32 correctedCW; + m_codeWordsBCHError[m_wordCount] = !bchDecode(cw, correctedCW); + m_codeWords[m_wordCount] = correctedCW; + m_wordCount++; + + // Check for sync code at start of batch + if ((m_wordCount == 1) + && ((correctedCW & 0xfffffffeU) != (PAGERDEMOD_POCSAG_SYNCCODE & 0xfffffffeU))) + { + // A message is only sent when the *next* address arrives, so without + // this the last message of a transmission would be silently dropped + if (m_addressValid) { + sendMessage(); + } + m_gotSOP = false; + m_addressValid = false; + // m_inverted is deliberately not reset - m_bits holds standard mapped bits, + // so the retained history stays valid and the sync search sets polarity again + } + + // Have we received a complete batch + if (m_wordCount == PAGERDEMOD_BATCH_WORDS) + { + // Decode it to addresses and messages + decodeBatch(); + + // Start a new batch + m_batchNumber++; + m_wordCount = 0; + } + + if (m_gotSOP) + { + m_bits = 0; + m_bitCount = 0; + } + // If we've just lost sync, keep the shift register, so the sliding search + // can resume on the next bit rather than waiting for 32 new ones + } +} + +// Send the message that has been accumulated for the current address +void PagerDemodSink::sendMessage() +{ + m_numericMessage = m_numericMessage.trimmed(); // Remove trailing spaces + if (getMessageQueueToChannel()) + { + // Convert from 7-bit to UTF-8 using user specified encoding + for (int i = 0; i < m_alphaMessage.size(); i++) + { + QChar c = m_alphaMessage[i]; + int idx = m_settings.m_sevenbit.indexOf(c.toLatin1()); + if (idx >= 0) { + c = QChar(m_settings.m_unicode[idx]); + } + m_alphaMessage[i] = c; + } + // Reverse reading order, if required + if (m_settings.m_reverse) { + std::reverse(m_alphaMessage.begin(), m_alphaMessage.end()); + } + // Send to channel and GUI + PagerDemod::MsgPagerMessage *msg = PagerDemod::MsgPagerMessage::create(m_address, m_functionBits, m_alphaMessage, m_numericMessage, m_parityErrors, m_bchErrors, m_baud); + getMessageQueueToChannel()->push(msg); + } + m_addressValid = false; +} + +// Decode the 20 message bits of a codeword as both numeric and 7-bit alphanumeric +void PagerDemodSink::addMessageBits(int messageBits) +{ + // Numeric format + for (int j = 16; j >= 0; j -= 4) + { + quint32 numericBits = (messageBits >> j) & 0xf; + numericBits = reverse(numericBits) >> (32-4); + // Spec has 0xa as 'spare', but other decoders treat is as . + const char numericChars[] = { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', 'U', ' ', '-', ')', '(' + }; + char numericChar = numericChars[numericBits]; + m_numericMessage.append(numericChar); + } + + // 7-bit ASCII alpnanumeric format + m_alphaBitBuffer = (m_alphaBitBuffer << 20) | messageBits; + m_alphaBitBufferBits += 20; + while (m_alphaBitBufferBits >= 7) + { + // Extract next 7-bit character from bit buffer + char c = (m_alphaBitBuffer >> (m_alphaBitBufferBits-7)) & 0x7f; + // Reverse bit ordering + c = reverse(c) >> (32-7); + // Add to received message string (excluding, null, end of text, end ot transmission) + if (c != 0 && c != 0x3 && c != 0x4) { + m_alphaMessage.append(c); + } + // Remove from bit buffer + m_alphaBitBufferBits -= 7; + if (m_alphaBitBufferBits == 0) { + m_alphaBitBuffer = 0; + } else { + m_alphaBitBuffer &= (1 << m_alphaBitBufferBits) - 1; + } + } +} + void PagerDemodSink::processOneSample(Complex &ci) { // FM demodulation @@ -398,6 +619,22 @@ void PagerDemodSink::processOneSample(Complex &ci) m_magsqCount++; + // Detect the baud rate from the preamble. Only run while unsynced: that is when the + // rate can change, and it keeps the per-rate filters out of the decoding path + if (!m_gotSOP) + { + int best = m_baudDetector.process(fmDemod); + + if (m_baudDetector.metric(best) >= m_preambleThreshold) + { + int baud = PagerDemodBaudDetector::m_rates[best]; + + if (baud != m_baud) { + setBaud(baud); + } + } + } + // Low pass filter Real filt = m_lowpassBaud.filter(fmDemod); @@ -414,120 +651,10 @@ void PagerDemodSink::processOneSample(Complex &ci) // Slice data int data = (filt - m_dcOffset) >= 0.0; - // Look for edge - A PLL here would be less susceptible to noise - if (data != m_dataPrev) - { - // Center in middle of bit - m_syncCount = m_samplesPerSymbol/2; - } - else - { - // Wait until centre of bit to sample it - m_syncCount--; - - if (m_syncCount <= 0) - { - // According to a variety of places on the web, high frequency is a 0, low is 1. - // While this seems to be correct in the UK, some IQ files I've obtained seem - // to be reversed, so we support both. - if (m_inverted) { - m_bit = data; - } else { - m_bit = !data; - } - - sample = true; - // Store in shift reg. MSB transmitted first - m_bits = (m_bits << 1) | m_bit; - m_bitCount++; - - if (m_bitCount > 32) { - m_bitCount = 32; - } - - if ((m_bitCount == 32) && !m_gotSOP) - { - // Look for synccode that starts a batch - allow two errors that can be corrected - if (m_bits == PAGERDEMOD_POCSAG_SYNCCODE) - { - m_gotSOP = true; - m_inverted = false; - } - else if (m_bits == PAGERDEMOD_POCSAG_SYNCCODE_INV) - { - m_gotSOP = true; - m_inverted = true; - } - else if (popcount((m_bits ^ PAGERDEMOD_POCSAG_SYNCCODE) & 0xfffffffeU) <= 2) - { - quint32 correctedCW; - if (bchDecode(m_bits, correctedCW) - && ((correctedCW & 0xfffffffeU) == (PAGERDEMOD_POCSAG_SYNCCODE & 0xfffffffeU))) - { - m_gotSOP = true; - m_inverted = false; - } - } - else if (popcount((m_bits ^ PAGERDEMOD_POCSAG_SYNCCODE_INV) & 0xfffffffeU) <= 2) - { - quint32 correctedCW; - if (bchDecode(~m_bits, correctedCW) - && ((correctedCW & 0xfffffffeU) == (PAGERDEMOD_POCSAG_SYNCCODE & 0xfffffffeU))) - { - m_gotSOP = true; - m_inverted = true; - } - } - - if (m_gotSOP) - { - // Reset demod state - m_bits = 0; - m_bitCount = 0; - m_codeWords[0] = PAGERDEMOD_POCSAG_SYNCCODE; - m_wordCount = 1; - m_addressValid = false; - } - } - else if ((m_bitCount == 32) && m_gotSOP) - { - // Got a complete codeword - use BCH decoding to fix any bit errors - quint32 correctedCW; - m_codeWordsBCHError[m_wordCount] = !bchDecode(m_bits, correctedCW); - m_codeWords[m_wordCount] = correctedCW; - m_wordCount++; - - // Check for sync code at start of batch - if ((m_wordCount == 1) - && ((correctedCW & 0xfffffffeU) != (PAGERDEMOD_POCSAG_SYNCCODE & 0xfffffffeU))) - { - m_gotSOP = false; - //m_thresholdMet = false; - m_addressValid = false; - m_inverted = false; - } - - // Have we received a complete batch - if (m_wordCount == PAGERDEMOD_BATCH_WORDS) - { - // Decode it to addresses and messages - decodeBatch(); - - // Start a new batch - m_batchNumber++; - m_wordCount = 0; - } - - m_bits = 0; - m_bitCount = 0; - } - - m_syncCount = m_samplesPerSymbol; - } - } + // Matched filter and timing recovery produce the bits + sample = matchedFilterAndDpll(filt - m_dcOffset); // Save data for edge detection - m_dataPrev = data; // Select signals to feed to scope Complex scopeSample; @@ -666,13 +793,13 @@ void PagerDemodSink::applySettings(const QStringList& settingsKeys, const PagerD m_phaseDiscri.setFMScaling(PagerDemodSettings::m_channelSampleRate / (2.0f * settings.m_fmDeviation)); } - if ((settingsKeys.contains("baud") && (settings.m_baud != m_settings.m_baud)) || force) + if (force) { - m_samplesPerSymbol = PagerDemodSettings::m_channelSampleRate / settings.m_baud; - qDebug() << "PagerDemodSink::applySettings: m_samplesPerSymbol: " << m_samplesPerSymbol; - - // Signal is a square wave - so include several harmonics - m_lowpassBaud.create(301, PagerDemodSettings::m_channelSampleRate, settings.m_baud * 5.0f); + // The baud rate is detected from the preamble rather than configured, as POCSAG + // networks mix rates and a single channel can carry different rates at different + // times. Start at the most common rate until the first preamble is detected. + m_baudDetector.create(PagerDemodSettings::m_channelSampleRate); + setBaud(1200); } if (force) { diff --git a/plugins/channelrx/demodpager/pagerdemodsink.h b/plugins/channelrx/demodpager/pagerdemodsink.h index 95bcf32a9..fae8b22da 100644 --- a/plugins/channelrx/demodpager/pagerdemodsink.h +++ b/plugins/channelrx/demodpager/pagerdemodsink.h @@ -22,6 +22,10 @@ #include +#include +#include +#include + #include "dsp/channelsamplesink.h" #include "dsp/phasediscri.h" #include "dsp/nco.h" @@ -43,6 +47,131 @@ class ChannelAPI; class PagerDemod; class ScopeVis; +// Detects the POCSAG preamble, which is 576 bits of 1010..., i.e. a square wave at half +// the baud rate. Correlates against that frequency and returns the fraction of the +// window's power in that bin: 1.0 for a pure sinusoid, 8/pi^2 = 0.81 for an ideal square +// wave and ~2/N for noise. The window is a whole number of cycles, so a sample leaving it +// has the same twiddle index as the one entering, making the update a single subtract. +class PagerDemodPreambleDetector +{ +public: + void create(int samplesPerSymbol, int cycles) + { + m_period = 2 * samplesPerSymbol; // one cycle of the 1010... pattern + m_n = cycles * m_period; + + m_cos.resize(m_period); + m_sin.resize(m_period); + + for (int i = 0; i < m_period; i++) + { + double a = 2.0 * M_PI * i / m_period; + m_cos[i] = cos(a); + m_sin[i] = sin(a); + } + + m_hist.assign(m_n, 0.0); + m_ptr = 0; + m_idx = 0; + m_i = 0.0; + m_q = 0.0; + m_p = 0.0; + m_filled = 0; + } + + double process(double x) + { + double old = m_hist[m_ptr]; + m_hist[m_ptr] = x; + m_ptr = (m_ptr + 1) % m_n; + + double d = x - old; + m_i += d * m_cos[m_idx]; + m_q += d * m_sin[m_idx]; + m_p += x*x - old*old; + m_idx = (m_idx + 1) % m_period; + + if (m_filled < m_n) + { + m_filled++; + return 0.0; + } + + double denom = (m_n / 2.0) * m_p; + + if (denom <= 0.0) { + return 0.0; + } + + double m = (m_i*m_i + m_q*m_q) / denom; + return std::min(1.0, std::max(0.0, m)); + } + +private: + int m_period = 0; + int m_n = 0; + std::vector m_cos; + std::vector m_sin; + std::vector m_hist; + int m_ptr = 0; + int m_idx = 0; + int m_filled = 0; + double m_i = 0.0; + double m_q = 0.0; + double m_p = 0.0; +}; + +// Determines which POCSAG baud rate is being transmitted, by running a preamble detector +// per rate. Each rate needs its own post detection filter, as the metric is a fraction of +// the window's power - sharing one wide filter would load the low rate detectors with +// noise they would not otherwise see, biasing detection towards the high rates. +class PagerDemodBaudDetector +{ +public: + static const int m_numRates = 3; + static constexpr int m_rates[m_numRates] = {512, 1200, 2400}; + + void create(int channelSampleRate) + { + for (int r = 0; r < m_numRates; r++) + { + // Short filters: the detector only has to give each correlator a roughly + // rate-appropriate noise bandwidth, so it doesn't need the decoder's + // selectivity - and this runs on every sample while unsynced + m_lowpass[r].create(63, channelSampleRate, m_rates[r] * 5.0f); + m_detector[r].create(channelSampleRate / m_rates[r], 8); + m_metric[r] = 0.0; + } + } + + //!< Feed the raw FM demodulator output; returns the index of the most likely rate + int process(Real fmDemod) + { + int best = 0; + + for (int r = 0; r < m_numRates; r++) + { + Real filt = m_lowpass[r].filter(fmDemod); + m_dc[r](filt); + m_metric[r] = m_detector[r].process(filt - m_dc[r].asDouble()); + + if (m_metric[r] > m_metric[best]) { + best = r; + } + } + + return best; + } + + double metric(int r) const { return m_metric[r]; } + +private: + Lowpass m_lowpass[m_numRates]; + PagerDemodPreambleDetector m_detector[m_numRates]; + MovingAverageUtil m_dc[m_numRates]; + double m_metric[m_numRates] = {0.0}; +}; + class PagerDemodSink : public ChannelSampleSink { public: PagerDemodSink(); @@ -93,6 +222,27 @@ private: int m_channelSampleRate; int m_channelFrequencyOffset; int m_samplesPerSymbol; // Number of samples per symbol + int m_baud; // Currently detected baud rate + PagerDemodBaudDetector m_baudDetector; + + // Matched filter (boxcar integrate and dump over a symbol, which is the matched filter + // for NRZ) and Gardner timing loop. The symbol clock free runs and is only nudged, so + // an isolated noise transition can't insert or delete a bit - and a bit slip destroys + // codeword alignment for the rest of the batch + std::vector m_mfBuf; // Last symbol's worth of samples + Real m_mfSum; // Sum of them, i.e. the matched filter output + int m_mfPtr; + Real m_timing; // Samples until the next symbol instant + Real m_yPrev; // Matched filter output at the last symbol instant + Real m_yMid; // ...and midway between the last two + bool m_gotMid; + Real m_yPower; // Running power, to normalise the timing error + //!< Gardner loop gain. Must be positive - a negative gain locks to the wrong phase and + //!< decodes nothing, while still sampling at the full symbol rate + static constexpr Real m_dpllGain = 0.2f; + //!< Fraction of power at half the baud rate needed to accept a preamble. Measured + //!< separation is wide - noise and data sit below 0.2, a real preamble above 0.85 + static constexpr double m_preambleThreshold = 0.5; NCO m_nco; Interpolator m_interpolator; @@ -114,13 +264,11 @@ private: PhaseDiscriminators m_phaseDiscri; // FM demodulator Lowpass m_lowpassBaud; // Low pass filter for FM demod output Real m_dcOffset; // Calculated DC offset of preamble - int m_dataPrev; // m_data for previous sample bool m_inverted; // Whether low frequency is a 1 or 0 int m_bit; // Sampled bit bool m_gotSOP; // Set when sync word received quint32 m_bits; // Received bit shift register int m_bitCount; // Number of bits in m_bits - int m_syncCount; // Sample count to centre of bit int m_batchNumber; // Count of batches in current transmission quint32 m_codeWords[PAGERDEMOD_BATCH_WORDS]; // Received codewords within a batch @@ -147,6 +295,11 @@ private: MessageQueue *getMessageQueueToChannel() { return m_messageQueueToChannel; } void sampleToScope(Complex sample); void decodeBatch(); + void sendMessage(); + void addMessageBits(int messageBits); + void setBaud(int baud); + void handleBit(int bit); + bool matchedFilterAndDpll(Real v); int xorBits(quint32 word, int firstBit, int lastBit); bool evenParity(quint32 word, int firstBit, int lastBit, int parityBit); quint32 reverse(quint32 x); diff --git a/plugins/channelrx/demodpager/readme.md b/plugins/channelrx/demodpager/readme.md index 1cf071a6c..2de3ac4ac 100644 --- a/plugins/channelrx/demodpager/readme.md +++ b/plugins/channelrx/demodpager/readme.md @@ -36,15 +36,13 @@ Adjusts the expected peak frequency deviation in 0.1 kHz steps from 1 to 6 kHz. Specifies the pager modulation. Currently only POCSAG is supported. -POCSAG uses FSK with 4.5kHz frequency shift, at 512, 1200 or 2400 baud. +POCSAG uses FSK with 4.5kHz frequency shift, at 512, 1200 or 2400 baud. The baud rate is +detected automatically from the preamble, as networks mix rates and a single channel can +carry different rates at different times. High frequency is typically 0, with low 1, but occasionally this appears to be reversed, so the demodulator supports either. Data is framed as specified in [ITU-R M.584-2](https://www.itu.int/dms_pubrec/itu-r/rec/m/R-REC-M.584-2-199711-I!!PDF-E.pdf) -

7: Baud

- -Specifies the baud rate. For POCSAG, this can be 512, 1200 or 2400. - -

8: Decode

+

7: Decode

Specifies how messages are decoded in the Message column in the table: @@ -56,7 +54,7 @@ Specifies how messages are decoded in the Message column in the table: The table has Numeric and Alphanumeric columns which always display the corresponding decode. -

9: Character encoding

+

8: Character encoding

Click to open the character encoding dialog, which allows a mapping from the received 7-bit alphanumeric characters to Unicode. @@ -64,36 +62,36 @@ Click to open the character encoding dialog, which allows a mapping from the rec Each row contains a mapping from a 7-bit value to a Unicode code point. Values should be entered in hexadecimal -

10: Find

+

9: Find

Entering a regular expression in the Find field displays only messages where the address matches the given regular expression. -

11: Clear Messages from table

+

10: Clear Messages from table

Pressing this button clears all messages from the table. -

12: UDP

+

11: UDP

When checked, received messages are forwarded to the specified UDP address (12) and port (13). The messages are forwarded as null terminated ASCII strings, in the format: data time address function alpha numeric -

13: UDP address

+

12: UDP address

IP address of the host to forward received messages to via UDP. -

14: UDP port

+

13: UDP port

UDP port number to forward received messages to. -

15: Filter Duplicates

+

14: Filter Duplicates

Check to filter (discard) duplicate messages. Right click to show the Duplicate Filter options dialog: - Match message only: When unchecked, compare address and message. When checked, compare only message, ignoring the address. - Match last message only: When unchecked the message is compared against all messages in the table. When checked, the message is compared against the last received message only. -

16: Open Notifications Dialog

+

15: Open Notifications Dialog

When clicked, opens the Notifications Dialog, which allows speech notifications or programs/scripts to be run when messages matching user-defined rules are received. @@ -116,15 +114,15 @@ In the Speech and Command strings, variables can be used to substitute data from To experiment with regular expressions, try [https://regexr.com/](https://regexr.com/). -

17: Start/stop Logging Messages to .csv File

+

16: Start/stop Logging Messages to .csv File

When checked, writes all received messages to a .csv file. -

18: .csv Log Filename

+

17: .csv Log Filename

Click to specify the name of the .csv file which received messages are logged to. -

19: Read Data from .csv File

+

18: Read Data from .csv File

Click to specify a previously written .csv log file, which is read and used to update the table. @@ -143,5 +141,6 @@ The received messages table displays each pager message received. * Numeric - Message decoded as numeric, regardless of Decode setting (8). * Even PE - Number of even parity errors detected in the code words of the message. * BCH PE - Number of uncorrectable BCH parity errors detected in the code words of the message. +* Baud - The baud rate the message was received at, detected automatically from the preamble. Right clicking on the table header allows you to select which columns to show. The columns can be reordered by left clicking and dragging the column header. Right clicking on an item in the table allows you to copy the value to the clipboard. diff --git a/swagger/sdrangel/api/swagger/include/PagerDemod.yaml b/swagger/sdrangel/api/swagger/include/PagerDemod.yaml index 7ba777ace..7c99bc7f1 100644 --- a/swagger/sdrangel/api/swagger/include/PagerDemod.yaml +++ b/swagger/sdrangel/api/swagger/include/PagerDemod.yaml @@ -1,9 +1,6 @@ PagerDemodSettings: description: PagerDemod properties: - baud: - description: "Baud rate" - type: integer decode: type: integer description: >