diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..05a6dec --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Force LF line endings for files that must run on Linux (Docker) +*.sh text eol=lf +Dockerfile text eol=lf diff --git a/Dockerfile b/Dockerfile index 77fc60c..3776650 100644 --- a/Dockerfile +++ b/Dockerfile @@ -256,6 +256,9 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy application code 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 RUN mkdir -p /app/data /app/data/weather_sat /app/data/radiosonde/logs diff --git a/build-multiarch.sh b/build-multiarch.sh index 4798be8..faa72bf 100755 --- a/build-multiarch.sh +++ b/build-multiarch.sh @@ -1,139 +1,139 @@ -#!/bin/bash -# INTERCEPT - Multi-architecture Docker image builder -# -# Builds for both linux/amd64 and linux/arm64 using Docker buildx. -# Run this on your x64 machine to cross-compile the arm64 image -# instead of building natively on the RPi5. -# -# Prerequisites (one-time setup): -# docker run --privileged --rm tonistiigi/binfmt --install all -# docker buildx create --name intercept-builder --use --bootstrap -# -# Usage: -# ./build-multiarch.sh # Build both platforms, load locally -# ./build-multiarch.sh --push # Build and push to registry -# ./build-multiarch.sh --arm64-only # Build arm64 only (for RPi) -# REGISTRY=ghcr.io/user ./build-multiarch.sh --push -# -# Environment variables: -# REGISTRY - Container registry (default: docker.io/library) -# IMAGE_NAME - Image name (default: intercept) -# IMAGE_TAG - Image tag (default: latest) - -set -euo pipefail - -# Configuration -REGISTRY="${REGISTRY:-}" -IMAGE_NAME="${IMAGE_NAME:-intercept}" -IMAGE_TAG="${IMAGE_TAG:-latest}" -BUILDER_NAME="intercept-builder" -PLATFORMS="linux/amd64,linux/arm64" - -# Parse arguments -PUSH=false -LOAD=false -ARM64_ONLY=false - -for arg in "$@"; do - case $arg in - --push) PUSH=true ;; - --load) LOAD=true ;; - --arm64-only) - ARM64_ONLY=true - PLATFORMS="linux/arm64" - ;; - --amd64-only) - PLATFORMS="linux/amd64" - ;; - --help|-h) - echo "Usage: $0 [--push] [--load] [--arm64-only] [--amd64-only]" - echo "" - echo "Options:" - echo " --push Push to container registry" - echo " --load Load into local Docker (single platform only)" - echo " --arm64-only Build arm64 only (for RPi5 deployment)" - echo " --amd64-only Build amd64 only" - echo "" - echo "Environment variables:" - echo " REGISTRY Container registry (e.g. ghcr.io/username)" - echo " IMAGE_NAME Image name (default: intercept)" - echo " IMAGE_TAG Image tag (default: latest)" - echo "" - echo "Examples:" - echo " $0 --push # Build both, push" - echo " REGISTRY=ghcr.io/myuser $0 --push # Push to GHCR" - echo " $0 --arm64-only --load # Build arm64, load locally" - echo " $0 --arm64-only --push && ssh rpi docker pull # Build + deploy to RPi" - exit 0 - ;; - *) - echo "Unknown option: $arg" - exit 1 - ;; - esac -done - -# Build full image reference -if [ -n "$REGISTRY" ]; then - FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" -else - FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}" -fi - -echo "============================================" -echo " INTERCEPT Multi-Architecture Builder" -echo "============================================" -echo " Image: ${FULL_IMAGE}" -echo " Platforms: ${PLATFORMS}" -echo " Push: ${PUSH}" -echo "============================================" -echo "" - -# Check if buildx builder exists, create if not -if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then - echo "Creating buildx builder: ${BUILDER_NAME}" - docker buildx create --name "$BUILDER_NAME" --use --bootstrap - - # Check for QEMU support - if ! docker run --rm --privileged tonistiigi/binfmt --install all >/dev/null 2>&1; then - echo "WARNING: QEMU binfmt setup may have failed." - echo "Run: docker run --privileged --rm tonistiigi/binfmt --install all" - fi -else - docker buildx use "$BUILDER_NAME" -fi - -# Build command -BUILD_CMD="docker buildx build --platform ${PLATFORMS} --tag ${FULL_IMAGE}" - -if [ "$PUSH" = true ]; then - BUILD_CMD="${BUILD_CMD} --push" - echo "Will push to: ${FULL_IMAGE}" -elif [ "$LOAD" = true ]; then - # --load only works with single platform - if echo "$PLATFORMS" | grep -q ","; then - echo "ERROR: --load only works with a single platform." - echo "Use --arm64-only or --amd64-only with --load." - exit 1 - fi - BUILD_CMD="${BUILD_CMD} --load" - echo "Will load into local Docker" -fi - -echo "" -echo "Building..." -echo "Command: ${BUILD_CMD} ." -echo "" - -$BUILD_CMD . - -echo "" -echo "============================================" -echo " Build complete!" -if [ "$PUSH" = true ]; then - echo " Image pushed to: ${FULL_IMAGE}" - echo "" - echo " Pull on RPi5:" - echo " docker pull ${FULL_IMAGE}" -fi -echo "============================================" +#!/bin/bash +# INTERCEPT - Multi-architecture Docker image builder +# +# Builds for both linux/amd64 and linux/arm64 using Docker buildx. +# Run this on your x64 machine to cross-compile the arm64 image +# instead of building natively on the RPi5. +# +# Prerequisites (one-time setup): +# docker run --privileged --rm tonistiigi/binfmt --install all +# docker buildx create --name intercept-builder --use --bootstrap +# +# Usage: +# ./build-multiarch.sh # Build both platforms, load locally +# ./build-multiarch.sh --push # Build and push to registry +# ./build-multiarch.sh --arm64-only # Build arm64 only (for RPi) +# REGISTRY=ghcr.io/user ./build-multiarch.sh --push +# +# Environment variables: +# REGISTRY - Container registry (default: docker.io/library) +# IMAGE_NAME - Image name (default: intercept) +# IMAGE_TAG - Image tag (default: latest) + +set -euo pipefail + +# Configuration +REGISTRY="${REGISTRY:-}" +IMAGE_NAME="${IMAGE_NAME:-intercept}" +IMAGE_TAG="${IMAGE_TAG:-latest}" +BUILDER_NAME="intercept-builder" +PLATFORMS="linux/amd64,linux/arm64" + +# Parse arguments +PUSH=false +LOAD=false +ARM64_ONLY=false + +for arg in "$@"; do + case $arg in + --push) PUSH=true ;; + --load) LOAD=true ;; + --arm64-only) + ARM64_ONLY=true + PLATFORMS="linux/arm64" + ;; + --amd64-only) + PLATFORMS="linux/amd64" + ;; + --help|-h) + echo "Usage: $0 [--push] [--load] [--arm64-only] [--amd64-only]" + echo "" + echo "Options:" + echo " --push Push to container registry" + echo " --load Load into local Docker (single platform only)" + echo " --arm64-only Build arm64 only (for RPi5 deployment)" + echo " --amd64-only Build amd64 only" + echo "" + echo "Environment variables:" + echo " REGISTRY Container registry (e.g. ghcr.io/username)" + echo " IMAGE_NAME Image name (default: intercept)" + echo " IMAGE_TAG Image tag (default: latest)" + echo "" + echo "Examples:" + echo " $0 --push # Build both, push" + echo " REGISTRY=ghcr.io/myuser $0 --push # Push to GHCR" + echo " $0 --arm64-only --load # Build arm64, load locally" + echo " $0 --arm64-only --push && ssh rpi docker pull # Build + deploy to RPi" + exit 0 + ;; + *) + echo "Unknown option: $arg" + exit 1 + ;; + esac +done + +# Build full image reference +if [ -n "$REGISTRY" ]; then + FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" +else + FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}" +fi + +echo "============================================" +echo " INTERCEPT Multi-Architecture Builder" +echo "============================================" +echo " Image: ${FULL_IMAGE}" +echo " Platforms: ${PLATFORMS}" +echo " Push: ${PUSH}" +echo "============================================" +echo "" + +# Check if buildx builder exists, create if not +if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then + echo "Creating buildx builder: ${BUILDER_NAME}" + docker buildx create --name "$BUILDER_NAME" --use --bootstrap + + # Check for QEMU support + if ! docker run --rm --privileged tonistiigi/binfmt --install all >/dev/null 2>&1; then + echo "WARNING: QEMU binfmt setup may have failed." + echo "Run: docker run --privileged --rm tonistiigi/binfmt --install all" + fi +else + docker buildx use "$BUILDER_NAME" +fi + +# Build command +BUILD_CMD="docker buildx build --platform ${PLATFORMS} --tag ${FULL_IMAGE}" + +if [ "$PUSH" = true ]; then + BUILD_CMD="${BUILD_CMD} --push" + echo "Will push to: ${FULL_IMAGE}" +elif [ "$LOAD" = true ]; then + # --load only works with single platform + if echo "$PLATFORMS" | grep -q ","; then + echo "ERROR: --load only works with a single platform." + echo "Use --arm64-only or --amd64-only with --load." + exit 1 + fi + BUILD_CMD="${BUILD_CMD} --load" + echo "Will load into local Docker" +fi + +echo "" +echo "Building..." +echo "Command: ${BUILD_CMD} ." +echo "" + +$BUILD_CMD . + +echo "" +echo "============================================" +echo " Build complete!" +if [ "$PUSH" = true ]; then + echo " Image pushed to: ${FULL_IMAGE}" + echo "" + echo " Pull on RPi5:" + echo " docker pull ${FULL_IMAGE}" +fi +echo "============================================" diff --git a/download-weather-sat-samples.sh b/download-weather-sat-samples.sh index ce13900..60237c3 100755 --- a/download-weather-sat-samples.sh +++ b/download-weather-sat-samples.sh @@ -1,30 +1,30 @@ -#!/usr/bin/env bash -# Download sample NOAA APT recordings for testing the weather satellite -# test-decode feature. These are FM-demodulated audio WAV files. -# -# Usage: -# ./download-weather-sat-samples.sh -# docker exec intercept /app/download-weather-sat-samples.sh - -set -euo pipefail - -SAMPLE_DIR="$(dirname "$0")/data/weather_sat/samples" -mkdir -p "$SAMPLE_DIR" - -echo "Downloading NOAA APT sample files to $SAMPLE_DIR ..." - -# Full satellite pass recorded over Argentina (NOAA, 11025 Hz mono WAV) -# Source: https://github.com/martinber/noaa-apt -if [ ! -f "$SAMPLE_DIR/noaa_apt_argentina.wav" ]; then - echo " -> noaa_apt_argentina.wav (18 MB) ..." - curl -fSL -o "$SAMPLE_DIR/noaa_apt_argentina.wav" \ - "https://noaa-apt.mbernardi.com.ar/examples/argentina.wav" -else - echo " -> noaa_apt_argentina.wav (already exists)" -fi - -echo "" -echo "Done. Test decode with:" -echo " Satellite: NOAA-18" -echo " File path: data/weather_sat/samples/noaa_apt_argentina.wav" -echo " Sample rate: 11025 Hz" +#!/usr/bin/env bash +# Download sample NOAA APT recordings for testing the weather satellite +# test-decode feature. These are FM-demodulated audio WAV files. +# +# Usage: +# ./download-weather-sat-samples.sh +# docker exec intercept /app/download-weather-sat-samples.sh + +set -euo pipefail + +SAMPLE_DIR="$(dirname "$0")/data/weather_sat/samples" +mkdir -p "$SAMPLE_DIR" + +echo "Downloading NOAA APT sample files to $SAMPLE_DIR ..." + +# Full satellite pass recorded over Argentina (NOAA, 11025 Hz mono WAV) +# Source: https://github.com/martinber/noaa-apt +if [ ! -f "$SAMPLE_DIR/noaa_apt_argentina.wav" ]; then + echo " -> noaa_apt_argentina.wav (18 MB) ..." + curl -fSL -o "$SAMPLE_DIR/noaa_apt_argentina.wav" \ + "https://noaa-apt.mbernardi.com.ar/examples/argentina.wav" +else + echo " -> noaa_apt_argentina.wav (already exists)" +fi + +echo "" +echo "Done. Test decode with:" +echo " Satellite: NOAA-18" +echo " File path: data/weather_sat/samples/noaa_apt_argentina.wav" +echo " Sample rate: 11025 Hz" diff --git a/routes/pager.py b/routes/pager.py index 7777ced..3c53d7a 100644 --- a/routes/pager.py +++ b/routes/pager.py @@ -55,6 +55,20 @@ def parse_multimon_output(line: str) -> dict[str, str] | None: '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_addr_match = re.match( r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$', diff --git a/static/js/components/signal-cards.js b/static/js/components/signal-cards.js index 07e81f8..43575d6 100644 --- a/static/js/components/signal-cards.js +++ b/static/js/components/signal-cards.js @@ -1492,6 +1492,7 @@ const SignalCards = (function() { muted.push(address); localStorage.setItem('mutedAddresses', JSON.stringify(muted)); showToast(`Source ${address} hidden from view`); + updateMutedIndicator(); // 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 => { @@ -1510,6 +1511,30 @@ const SignalCards = (function() { 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) */ @@ -2262,6 +2287,8 @@ const SignalCards = (function() { copyMessage, muteAddress, isAddressMuted, + unmuteAll, + updateMutedIndicator, showOnMap, showStationRawData, showSignalDetails, diff --git a/templates/index.html b/templates/index.html index 1974eb6..0a84bbc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3611,7 +3611,7 @@ // Pager message filter settings let pagerFilters = { - hideToneOnly: true, + hideToneOnly: false, keywords: [] }; @@ -3663,7 +3663,11 @@ const saved = localStorage.getItem('pagerFilters'); if (saved) { 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) { console.warn('Failed to load pager filters:', e); } @@ -3964,6 +3968,7 @@ // Load pager message filters loadPagerFilters(); + if (typeof SignalCards !== 'undefined') SignalCards.updateMutedIndicator(); // Initialize dropdown nav active state updateDropdownActiveState(); @@ -6953,20 +6958,21 @@ return null; // Can't determine } - // Check for high entropy (random-looking data) - 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]+$/; + // Check for non-printable characters (outside printable ASCII range) const hasNonPrintable = /[^\x20-\x7E]/.test(message); - if (printableRatio > 0.8 && !hasNonPrintable) { - return false; // Likely plaintext - } else if (hexPattern.test(message.replace(/\s/g, '')) || hasNonPrintable) { - return true; // Likely encrypted or encoded + // Check for common encrypted patterns (hex strings) + const hexPattern = /^[0-9A-Fa-f\s]+$/; + + 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 diff --git a/templates/partials/modes/pager.html b/templates/partials/modes/pager.html index 7d75119..dba62a4 100644 --- a/templates/partials/modes/pager.html +++ b/templates/partials/modes/pager.html @@ -62,7 +62,7 @@

Message Filters

@@ -73,6 +73,14 @@
Messages matching these keywords will be hidden from display but still logged.
+ diff --git a/tests/test_pager_parser.py b/tests/test_pager_parser.py new file mode 100644 index 0000000..4437bee --- /dev/null +++ b/tests/test_pager_parser.py @@ -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