Merge pull request #175 from thatsatechnique/fix/pager-display-classification

fix: improve pager message display and mute visibility
This commit is contained in:
Smittix
2026-03-04 18:00:33 +00:00
committed by GitHub
9 changed files with 366 additions and 182 deletions

3
.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
# Force LF line endings for files that must run on Linux (Docker)
*.sh text eol=lf
Dockerfile text eol=lf

View File

@@ -256,6 +256,9 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code # Copy application code
COPY . . COPY . .
# Strip Windows CRLF from shell scripts (git autocrlf can re-introduce them)
RUN find . -name '*.sh' -exec sed -i 's/\r$//' {} +
# Create data directory for persistence # Create data directory for persistence
RUN mkdir -p /app/data /app/data/weather_sat /app/data/radiosonde/logs RUN mkdir -p /app/data /app/data/weather_sat /app/data/radiosonde/logs

View File

@@ -55,6 +55,20 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
'message': pocsag_match.group(5).strip() or '[No Message]' 'message': pocsag_match.group(5).strip() or '[No Message]'
} }
# POCSAG parsing - other content types (catch-all for non-Alpha/Numeric labels)
pocsag_other_match = re.match(
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(\w+):\s*(.*)',
line
)
if pocsag_other_match:
return {
'protocol': pocsag_other_match.group(1),
'address': pocsag_other_match.group(2),
'function': pocsag_other_match.group(3),
'msg_type': pocsag_other_match.group(4),
'message': pocsag_other_match.group(5).strip() or '[No Message]'
}
# POCSAG parsing - address only (no message content) # POCSAG parsing - address only (no message content)
pocsag_addr_match = re.match( pocsag_addr_match = re.match(
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$', r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$',

View File

@@ -1492,6 +1492,7 @@ const SignalCards = (function() {
muted.push(address); muted.push(address);
localStorage.setItem('mutedAddresses', JSON.stringify(muted)); localStorage.setItem('mutedAddresses', JSON.stringify(muted));
showToast(`Source ${address} hidden from view`); showToast(`Source ${address} hidden from view`);
updateMutedIndicator();
// Hide existing cards with this address // Hide existing cards with this address
document.querySelectorAll(`.signal-card[data-address="${address}"], .signal-card[data-callsign="${address}"], .signal-card[data-sensor-id="${address}"]`).forEach(card => { document.querySelectorAll(`.signal-card[data-address="${address}"], .signal-card[data-callsign="${address}"], .signal-card[data-sensor-id="${address}"]`).forEach(card => {
@@ -1510,6 +1511,30 @@ const SignalCards = (function() {
return muted.includes(address); return muted.includes(address);
} }
/**
* Unmute all addresses and refresh display
*/
function unmuteAll() {
localStorage.setItem('mutedAddresses', '[]');
updateMutedIndicator();
showToast('All sources unmuted');
// Reload to re-display previously muted messages
location.reload();
}
/**
* Update the muted address count indicator in the sidebar
*/
function updateMutedIndicator() {
const muted = JSON.parse(localStorage.getItem('mutedAddresses') || '[]');
const info = document.getElementById('mutedAddressInfo');
const count = document.getElementById('mutedAddressCount');
if (info && count) {
count.textContent = muted.length;
info.style.display = muted.length > 0 ? 'block' : 'none';
}
}
/** /**
* Show location on map (for APRS) * Show location on map (for APRS)
*/ */
@@ -2262,6 +2287,8 @@ const SignalCards = (function() {
copyMessage, copyMessage,
muteAddress, muteAddress,
isAddressMuted, isAddressMuted,
unmuteAll,
updateMutedIndicator,
showOnMap, showOnMap,
showStationRawData, showStationRawData,
showSignalDetails, showSignalDetails,

View File

@@ -3611,7 +3611,7 @@
// Pager message filter settings // Pager message filter settings
let pagerFilters = { let pagerFilters = {
hideToneOnly: true, hideToneOnly: false,
keywords: [] keywords: []
}; };
@@ -3663,7 +3663,11 @@
const saved = localStorage.getItem('pagerFilters'); const saved = localStorage.getItem('pagerFilters');
if (saved) { if (saved) {
try { try {
pagerFilters = JSON.parse(saved); const parsed = JSON.parse(saved);
// Only persist keywords across sessions.
// hideToneOnly defaults to false every session so users
// always see the full traffic stream unless they opt-in.
if (Array.isArray(parsed.keywords)) pagerFilters.keywords = parsed.keywords;
} catch (e) { } catch (e) {
console.warn('Failed to load pager filters:', e); console.warn('Failed to load pager filters:', e);
} }
@@ -3964,6 +3968,7 @@
// Load pager message filters // Load pager message filters
loadPagerFilters(); loadPagerFilters();
if (typeof SignalCards !== 'undefined') SignalCards.updateMutedIndicator();
// Initialize dropdown nav active state // Initialize dropdown nav active state
updateDropdownActiveState(); updateDropdownActiveState();
@@ -6953,20 +6958,21 @@
return null; // Can't determine return null; // Can't determine
} }
// Check for high entropy (random-looking data) // Check for non-printable characters (outside printable ASCII range)
const printableRatio = (message.match(/[a-zA-Z0-9\s.,!?-]/g) || []).length / message.length;
// Check for common encrypted patterns (hex strings, base64-like)
const hexPattern = /^[0-9A-Fa-f\s]+$/;
const hasNonPrintable = /[^\x20-\x7E]/.test(message); const hasNonPrintable = /[^\x20-\x7E]/.test(message);
if (printableRatio > 0.8 && !hasNonPrintable) { // Check for common encrypted patterns (hex strings)
return false; // Likely plaintext const hexPattern = /^[0-9A-Fa-f\s]+$/;
} else if (hexPattern.test(message.replace(/\s/g, '')) || hasNonPrintable) {
return true; // Likely encrypted or encoded if (hasNonPrintable) {
return true; // Contains non-printable chars — likely encrypted or encoded
}
if (hexPattern.test(message.replace(/\s/g, ''))) {
return true; // Pure hex data — likely encoded
} }
return null; // Unknown // All printable ASCII (covers base64, structured data, punctuation, etc.)
return false; // Likely plaintext
} }
// Generate device fingerprint // Generate device fingerprint

View File

@@ -62,7 +62,7 @@
<h3>Message Filters</h3> <h3>Message Filters</h3>
<div class="checkbox-group" style="margin-bottom: 10px;"> <div class="checkbox-group" style="margin-bottom: 10px;">
<label> <label>
<input type="checkbox" id="filterToneOnly" checked onchange="savePagerFilters()"> <input type="checkbox" id="filterToneOnly" onchange="savePagerFilters()">
Hide "Tone Only" messages Hide "Tone Only" messages
</label> </label>
</div> </div>
@@ -73,6 +73,14 @@
<div class="info-text" style="font-size: 10px; color: #666; margin-top: 5px;"> <div class="info-text" style="font-size: 10px; color: #666; margin-top: 5px;">
Messages matching these keywords will be hidden from display but still logged. Messages matching these keywords will be hidden from display but still logged.
</div> </div>
<div id="mutedAddressInfo" style="margin-top: 8px; display: none;">
<span style="font-size: 11px; color: var(--text-dim, #888);">
<span id="mutedAddressCount">0</span> muted source(s)
</span>
<button onclick="SignalCards.unmuteAll()" style="margin-left: 6px; font-size: 10px; padding: 2px 8px; cursor: pointer; background: transparent; border: 1px solid var(--border-color, #444); color: var(--text-secondary, #aaa); border-radius: 3px;">
Unmute All
</button>
</div>
</div> </div>
<!-- Antenna Guide --> <!-- Antenna Guide -->

123
tests/test_pager_parser.py Normal file
View File

@@ -0,0 +1,123 @@
"""Tests for pager multimon-ng output parser."""
from __future__ import annotations
from routes.pager import parse_multimon_output
class TestPocsagAlphaNumeric:
"""Standard POCSAG messages with Alpha or Numeric content."""
def test_alpha_message(self):
line = "POCSAG1200: Address: 1337 Function: 3 Alpha: Hello World"
result = parse_multimon_output(line)
assert result is not None
assert result["protocol"] == "POCSAG1200"
assert result["address"] == "1337"
assert result["function"] == "3"
assert result["msg_type"] == "Alpha"
assert result["message"] == "Hello World"
def test_numeric_message(self):
line = "POCSAG1200: Address: 500 Function: 2 Numeric: 55512345"
result = parse_multimon_output(line)
assert result is not None
assert result["msg_type"] == "Numeric"
assert result["message"] == "55512345"
def test_alpha_empty_content(self):
line = "POCSAG1200: Address: 200 Function: 3 Alpha: "
result = parse_multimon_output(line)
assert result is not None
assert result["msg_type"] == "Alpha"
assert result["message"] == "[No Message]"
def test_pocsag512_baud(self):
line = "POCSAG512: Address: 12345 Function: 0 Alpha: test"
result = parse_multimon_output(line)
assert result is not None
assert result["protocol"] == "POCSAG512"
assert result["message"] == "test"
def test_pocsag2400_baud(self):
line = "POCSAG2400: Address: 9999 Function: 1 Numeric: 0"
result = parse_multimon_output(line)
assert result is not None
assert result["protocol"] == "POCSAG2400"
def test_alpha_with_special_characters(self):
"""Base64, colons, equals signs, and other punctuation should parse."""
line = "POCSAG1200: Address: 1337 Function: 3 Alpha: 0:U0tZLQ=="
result = parse_multimon_output(line)
assert result is not None
assert result["msg_type"] == "Alpha"
assert result["message"] == "0:U0tZLQ=="
class TestPocsagCatchAll:
"""Catch-all pattern for non-standard content type labels."""
def test_unknown_content_label(self):
"""Future multimon-ng versions might emit new type labels."""
line = "POCSAG1200: Address: 1337 Function: 3 Skyper: some data"
result = parse_multimon_output(line)
assert result is not None
assert result["msg_type"] == "Skyper"
assert result["message"] == "some data"
def test_char_content_label(self):
line = "POCSAG1200: Address: 1337 Function: 2 Char: ABCDEF"
result = parse_multimon_output(line)
assert result is not None
assert result["msg_type"] == "Char"
assert result["message"] == "ABCDEF"
def test_catchall_empty_content(self):
line = "POCSAG1200: Address: 1337 Function: 2 Raw: "
result = parse_multimon_output(line)
assert result is not None
assert result["msg_type"] == "Raw"
assert result["message"] == "[No Message]"
def test_alpha_still_matches_first(self):
"""Alpha/Numeric pattern should take priority over catch-all."""
line = "POCSAG1200: Address: 100 Function: 3 Alpha: priority"
result = parse_multimon_output(line)
assert result is not None
assert result["msg_type"] == "Alpha"
assert result["message"] == "priority"
class TestPocsagToneOnly:
"""Address-only lines with no message content."""
def test_tone_only(self):
line = "POCSAG1200: Address: 1977540 Function: 2"
result = parse_multimon_output(line)
assert result is not None
assert result["msg_type"] == "Tone"
assert result["message"] == "[Tone Only]"
assert result["address"] == "1977540"
def test_tone_only_with_trailing_spaces(self):
line = "POCSAG1200: Address: 1337 Function: 1 "
result = parse_multimon_output(line)
assert result is not None
assert result["msg_type"] == "Tone"
class TestFlexParsing:
"""FLEX protocol output parsing."""
def test_simple_flex(self):
line = "FLEX: Some flex message here"
result = parse_multimon_output(line)
assert result is not None
assert result["protocol"] == "FLEX"
assert result["message"] == "Some flex message here"
def test_no_match(self):
"""Unrecognized lines should return None."""
assert parse_multimon_output("multimon-ng 1.2.0") is None
assert parse_multimon_output("") is None
assert parse_multimon_output("Enabled decoders: POCSAG512") is None