diff --git a/app.py b/app.py index 0754fff..1584e83 100644 --- a/app.py +++ b/app.py @@ -198,6 +198,11 @@ tscm_lock = threading.Lock() subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) subghz_lock = threading.Lock() +# CW/Morse code decoder +morse_process = None +morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +morse_lock = threading.Lock() + # Deauth Attack Detection deauth_detector = None deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) @@ -755,6 +760,7 @@ def health_check() -> Response: 'wifi': wifi_active, 'bluetooth': bt_active, 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), + 'morse': morse_process is not None and (morse_process.poll() is None if morse_process else False), 'subghz': _get_subghz_active(), }, 'data': { @@ -772,7 +778,7 @@ def health_check() -> Response: def kill_all() -> Response: """Kill all decoder, WiFi, and Bluetooth processes.""" global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process - global vdl2_process + global vdl2_process, morse_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process # Import adsb and ais modules to reset their state @@ -825,6 +831,10 @@ def kill_all() -> Response: with vdl2_lock: vdl2_process = None + # Reset Morse state + with morse_lock: + morse_process = None + # Reset APRS state with aprs_lock: aprs_process = None diff --git a/routes/__init__.py b/routes/__init__.py index cf197b9..e1e1319 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -16,6 +16,7 @@ def register_blueprints(app): from .gps import gps_bp from .listening_post import receiver_bp from .meshtastic import meshtastic_bp + from .morse import morse_bp from .offline import offline_bp from .pager import pager_bp from .recordings import recordings_bp @@ -73,6 +74,7 @@ def register_blueprints(app): app.register_blueprint(space_weather_bp) # Space weather monitoring app.register_blueprint(signalid_bp) # External signal ID enrichment app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder + app.register_blueprint(morse_bp) # CW/Morse code decoder # Initialize TSCM state with queue and lock from app import app as app_module diff --git a/routes/morse.py b/routes/morse.py new file mode 100644 index 0000000..56800a2 --- /dev/null +++ b/routes/morse.py @@ -0,0 +1,251 @@ +"""CW/Morse code decoder routes.""" + +from __future__ import annotations + +import contextlib +import queue +import subprocess +import threading +from typing import Any + +from flask import Blueprint, Response, jsonify, request + +import app as app_module +from utils.event_pipeline import process_event +from utils.logging import sensor_logger as logger +from utils.morse import morse_decoder_thread +from utils.process import register_process, safe_terminate, unregister_process +from utils.sdr import SDRFactory, SDRType +from utils.sse import sse_stream_fanout +from utils.validation import ( + validate_device_index, + validate_frequency, + validate_gain, + validate_ppm, +) + +morse_bp = Blueprint('morse', __name__) + +# Track which device is being used +morse_active_device: int | None = None + + +def _validate_tone_freq(value: Any) -> float: + """Validate CW tone frequency (300-1200 Hz).""" + try: + freq = float(value) + if not 300 <= freq <= 1200: + raise ValueError("Tone frequency must be between 300 and 1200 Hz") + return freq + except (ValueError, TypeError) as e: + raise ValueError(f"Invalid tone frequency: {value}") from e + + +def _validate_wpm(value: Any) -> int: + """Validate words per minute (5-50).""" + try: + wpm = int(value) + if not 5 <= wpm <= 50: + raise ValueError("WPM must be between 5 and 50") + return wpm + except (ValueError, TypeError) as e: + raise ValueError(f"Invalid WPM: {value}") from e + + +@morse_bp.route('/morse/start', methods=['POST']) +def start_morse() -> Response: + global morse_active_device + + with app_module.morse_lock: + if app_module.morse_process: + return jsonify({'status': 'error', 'message': 'Morse decoder already running'}), 409 + + data = request.json or {} + + # Validate standard SDR inputs + try: + freq = validate_frequency(data.get('frequency', '14.060')) + gain = validate_gain(data.get('gain', '0')) + ppm = validate_ppm(data.get('ppm', '0')) + device = validate_device_index(data.get('device', '0')) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + # Validate Morse-specific inputs + try: + tone_freq = _validate_tone_freq(data.get('tone_freq', '700')) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + try: + wpm = _validate_wpm(data.get('wpm', '15')) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + # Claim SDR device + device_int = int(device) + error = app_module.claim_sdr_device(device_int, 'morse') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error, + }), 409 + morse_active_device = device_int + + # Clear queue + while not app_module.morse_queue.empty(): + try: + app_module.morse_queue.get_nowait() + except queue.Empty: + break + + # Build rtl_fm USB demodulation command + sdr_type_str = data.get('sdr_type', 'rtlsdr') + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR + + sdr_device = SDRFactory.create_default_device(sdr_type, index=device) + builder = SDRFactory.get_builder(sdr_device.sdr_type) + + sample_rate = 8000 + bias_t = data.get('bias_t', False) + + rtl_cmd = builder.build_fm_demod_command( + device=sdr_device, + frequency_mhz=freq, + sample_rate=sample_rate, + gain=float(gain) if gain and gain != '0' else None, + ppm=int(ppm) if ppm and ppm != '0' else None, + modulation='usb', + bias_t=bias_t, + ) + + full_cmd = ' '.join(rtl_cmd) + logger.info(f"Morse decoder running: {full_cmd}") + + try: + rtl_process = subprocess.Popen( + rtl_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + register_process(rtl_process) + + # Monitor rtl_fm stderr + def monitor_stderr(): + for line in rtl_process.stderr: + err_text = line.decode('utf-8', errors='replace').strip() + if err_text: + logger.debug(f"[rtl_fm/morse] {err_text}") + + stderr_thread = threading.Thread(target=monitor_stderr) + stderr_thread.daemon = True + stderr_thread.start() + + # Start Morse decoder thread + stop_event = threading.Event() + decoder_thread = threading.Thread( + target=morse_decoder_thread, + args=( + rtl_process.stdout, + app_module.morse_queue, + stop_event, + sample_rate, + tone_freq, + wpm, + ), + ) + decoder_thread.daemon = True + decoder_thread.start() + + app_module.morse_process = rtl_process + app_module.morse_process._stop_decoder = stop_event + app_module.morse_process._decoder_thread = decoder_thread + + app_module.morse_queue.put({'type': 'status', 'status': 'started'}) + + return jsonify({ + 'status': 'started', + 'command': full_cmd, + 'tone_freq': tone_freq, + 'wpm': wpm, + }) + + except FileNotFoundError as e: + if morse_active_device is not None: + app_module.release_sdr_device(morse_active_device) + morse_active_device = None + return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}), 400 + + except Exception as e: + # Clean up rtl_fm if it was started + try: + rtl_process.terminate() + rtl_process.wait(timeout=2) + except Exception: + with contextlib.suppress(Exception): + rtl_process.kill() + unregister_process(rtl_process) + if morse_active_device is not None: + app_module.release_sdr_device(morse_active_device) + morse_active_device = None + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@morse_bp.route('/morse/stop', methods=['POST']) +def stop_morse() -> Response: + global morse_active_device + + with app_module.morse_lock: + if app_module.morse_process: + # Signal decoder thread to stop + stop_event = getattr(app_module.morse_process, '_stop_decoder', None) + if stop_event: + stop_event.set() + + safe_terminate(app_module.morse_process) + unregister_process(app_module.morse_process) + app_module.morse_process = None + + if morse_active_device is not None: + app_module.release_sdr_device(morse_active_device) + morse_active_device = None + + app_module.morse_queue.put({'type': 'status', 'status': 'stopped'}) + return jsonify({'status': 'stopped'}) + + return jsonify({'status': 'not_running'}) + + +@morse_bp.route('/morse/status') +def morse_status() -> Response: + with app_module.morse_lock: + running = ( + app_module.morse_process is not None + and app_module.morse_process.poll() is None + ) + return jsonify({'running': running}) + + +@morse_bp.route('/morse/stream') +def morse_stream() -> Response: + def _on_msg(msg: dict[str, Any]) -> None: + process_event('morse', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.morse_queue, + channel_key='morse', + timeout=1.0, + keepalive_interval=30.0, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' + return response diff --git a/static/css/modes/morse.css b/static/css/modes/morse.css new file mode 100644 index 0000000..4e46e09 --- /dev/null +++ b/static/css/modes/morse.css @@ -0,0 +1,127 @@ +/* Morse Code / CW Decoder Styles */ + +/* Scope canvas container */ +.morse-scope-container { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 8px; + margin-bottom: 12px; +} + +.morse-scope-container canvas { + width: 100%; + height: 120px; + display: block; + border-radius: 4px; +} + +/* Decoded text panel */ +.morse-decoded-panel { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 16px; + min-height: 200px; + max-height: 400px; + overflow-y: auto; + font-family: var(--font-mono); + font-size: 18px; + line-height: 1.6; + color: var(--text-primary); + word-wrap: break-word; + flex: 1; +} + +.morse-decoded-panel:empty::before { + content: 'Decoded text will appear here...'; + color: var(--text-dim); + font-size: 14px; + font-style: italic; +} + +/* Individual decoded character with fade-in */ +.morse-char { + display: inline; + animation: morseFadeIn 0.3s ease-out; + position: relative; +} + +@keyframes morseFadeIn { + from { + opacity: 0; + color: var(--accent-cyan); + } + to { + opacity: 1; + color: var(--text-primary); + } +} + +/* Small Morse notation above character */ +.morse-char-morse { + font-size: 9px; + color: var(--text-dim); + letter-spacing: 1px; + display: block; + line-height: 1; + margin-bottom: -2px; +} + +/* Reference grid */ +.morse-ref-grid { + transition: max-height 0.3s ease, opacity 0.3s ease; + max-height: 500px; + opacity: 1; + overflow: hidden; +} + +.morse-ref-grid.collapsed { + max-height: 0; + opacity: 0; +} + +/* Toolbar: export/copy/clear */ +.morse-toolbar { + display: flex; + gap: 6px; + margin-bottom: 8px; + flex-wrap: wrap; +} + +.morse-toolbar .btn { + font-size: 11px; + padding: 4px 10px; +} + +/* Status bar at bottom */ +.morse-status-bar { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + color: var(--text-dim); + padding: 6px 0; + border-top: 1px solid var(--border-color); + margin-top: 8px; +} + +.morse-status-bar .status-item { + display: flex; + align-items: center; + gap: 4px; +} + +/* Visuals container layout */ +#morseVisuals { + flex-direction: column; + gap: 12px; + padding: 16px; + height: 100%; +} + +/* Word space styling */ +.morse-word-space { + display: inline; + width: 0.5em; +} diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js new file mode 100644 index 0000000..1ba5acb --- /dev/null +++ b/static/js/modes/morse.js @@ -0,0 +1,379 @@ +/** + * Morse Code (CW) decoder module. + * + * IIFE providing start/stop controls, SSE streaming, scope canvas, + * decoded text display, and export capabilities. + */ +var MorseMode = (function () { + 'use strict'; + + var state = { + running: false, + initialized: false, + eventSource: null, + charCount: 0, + decodedLog: [], // { timestamp, morse, char } + }; + + // Scope state + var scopeCtx = null; + var scopeAnim = null; + var scopeHistory = []; + var SCOPE_HISTORY_LEN = 300; + var scopeThreshold = 0; + var scopeToneOn = false; + + // ---- Initialization ---- + + function init() { + if (state.initialized) { + checkStatus(); + return; + } + state.initialized = true; + checkStatus(); + } + + function destroy() { + disconnectSSE(); + stopScope(); + } + + // ---- Status ---- + + function checkStatus() { + fetch('/morse/status') + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.running) { + state.running = true; + updateUI(true); + connectSSE(); + startScope(); + } else { + state.running = false; + updateUI(false); + } + }) + .catch(function () {}); + } + + // ---- Start / Stop ---- + + function start() { + if (state.running) return; + + var payload = { + frequency: document.getElementById('morseFrequency').value || '14.060', + gain: document.getElementById('morseGain').value || '0', + ppm: document.getElementById('morsePPM').value || '0', + device: document.getElementById('morseDevice').value || '0', + sdr_type: document.getElementById('morseSdrType').value || 'rtlsdr', + tone_freq: document.getElementById('morseToneFreq').value || '700', + wpm: document.getElementById('morseWpm').value || '15', + bias_t: document.getElementById('morseBiasT').checked, + }; + + fetch('/morse/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.status === 'started') { + state.running = true; + state.charCount = 0; + state.decodedLog = []; + updateUI(true); + connectSSE(); + startScope(); + clearDecodedText(); + } else { + alert('Error: ' + (data.message || 'Unknown error')); + } + }) + .catch(function (err) { + alert('Failed to start Morse decoder: ' + err); + }); + } + + function stop() { + fetch('/morse/stop', { method: 'POST' }) + .then(function (r) { return r.json(); }) + .then(function () { + state.running = false; + updateUI(false); + disconnectSSE(); + stopScope(); + }) + .catch(function () {}); + } + + // ---- SSE ---- + + function connectSSE() { + disconnectSSE(); + var es = new EventSource('/morse/stream'); + + es.onmessage = function (e) { + try { + var msg = JSON.parse(e.data); + handleMessage(msg); + } catch (_) {} + }; + + es.onerror = function () { + // Reconnect handled by browser + }; + + state.eventSource = es; + } + + function disconnectSSE() { + if (state.eventSource) { + state.eventSource.close(); + state.eventSource = null; + } + } + + function handleMessage(msg) { + var type = msg.type; + + if (type === 'scope') { + // Update scope data + var amps = msg.amplitudes || []; + for (var i = 0; i < amps.length; i++) { + scopeHistory.push(amps[i]); + if (scopeHistory.length > SCOPE_HISTORY_LEN) { + scopeHistory.shift(); + } + } + scopeThreshold = msg.threshold || 0; + scopeToneOn = msg.tone_on || false; + + } else if (type === 'morse_char') { + appendChar(msg.char, msg.morse, msg.timestamp); + + } else if (type === 'morse_space') { + appendSpace(); + + } else if (type === 'status') { + if (msg.status === 'stopped') { + state.running = false; + updateUI(false); + disconnectSSE(); + stopScope(); + } + } else if (type === 'error') { + console.error('Morse error:', msg.text); + } + } + + // ---- Decoded text ---- + + function appendChar(ch, morse, timestamp) { + state.charCount++; + state.decodedLog.push({ timestamp: timestamp, morse: morse, char: ch }); + + var panel = document.getElementById('morseDecodedText'); + if (!panel) return; + + var span = document.createElement('span'); + span.className = 'morse-char'; + span.textContent = ch; + span.title = morse + ' (' + timestamp + ')'; + panel.appendChild(span); + + // Auto-scroll + panel.scrollTop = panel.scrollHeight; + + // Update count + var countEl = document.getElementById('morseCharCount'); + if (countEl) countEl.textContent = state.charCount + ' chars'; + } + + function appendSpace() { + var panel = document.getElementById('morseDecodedText'); + if (!panel) return; + + var span = document.createElement('span'); + span.className = 'morse-word-space'; + span.textContent = ' '; + panel.appendChild(span); + } + + function clearDecodedText() { + var panel = document.getElementById('morseDecodedText'); + if (panel) panel.innerHTML = ''; + state.charCount = 0; + state.decodedLog = []; + var countEl = document.getElementById('morseCharCount'); + if (countEl) countEl.textContent = '0 chars'; + } + + // ---- Scope canvas ---- + + function startScope() { + var canvas = document.getElementById('morseScopeCanvas'); + if (!canvas) return; + + var dpr = window.devicePixelRatio || 1; + var rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = 120 * dpr; + canvas.style.height = '120px'; + + scopeCtx = canvas.getContext('2d'); + scopeCtx.scale(dpr, dpr); + scopeHistory = []; + + function draw() { + if (!scopeCtx) return; + var w = rect.width; + var h = 120; + + scopeCtx.fillStyle = '#0a0e14'; + scopeCtx.fillRect(0, 0, w, h); + + if (scopeHistory.length === 0) { + scopeAnim = requestAnimationFrame(draw); + return; + } + + // Find max for normalization + var maxVal = 0; + for (var i = 0; i < scopeHistory.length; i++) { + if (scopeHistory[i] > maxVal) maxVal = scopeHistory[i]; + } + if (maxVal === 0) maxVal = 1; + + var barW = w / SCOPE_HISTORY_LEN; + var threshNorm = scopeThreshold / maxVal; + + // Draw amplitude bars + for (var j = 0; j < scopeHistory.length; j++) { + var norm = scopeHistory[j] / maxVal; + var barH = norm * (h - 10); + var x = j * barW; + var y = h - barH; + + // Green if above threshold, gray if below + if (scopeHistory[j] > scopeThreshold) { + scopeCtx.fillStyle = '#00ff88'; + } else { + scopeCtx.fillStyle = '#334455'; + } + scopeCtx.fillRect(x, y, Math.max(barW - 1, 1), barH); + } + + // Draw threshold line + if (scopeThreshold > 0) { + var threshY = h - (threshNorm * (h - 10)); + scopeCtx.strokeStyle = '#ff4444'; + scopeCtx.lineWidth = 1; + scopeCtx.setLineDash([4, 4]); + scopeCtx.beginPath(); + scopeCtx.moveTo(0, threshY); + scopeCtx.lineTo(w, threshY); + scopeCtx.stroke(); + scopeCtx.setLineDash([]); + } + + // Tone indicator + if (scopeToneOn) { + scopeCtx.fillStyle = '#00ff88'; + scopeCtx.beginPath(); + scopeCtx.arc(w - 12, 12, 5, 0, Math.PI * 2); + scopeCtx.fill(); + } + + scopeAnim = requestAnimationFrame(draw); + } + + draw(); + } + + function stopScope() { + if (scopeAnim) { + cancelAnimationFrame(scopeAnim); + scopeAnim = null; + } + scopeCtx = null; + } + + // ---- Export ---- + + function exportTxt() { + var text = state.decodedLog.map(function (e) { return e.char; }).join(''); + downloadFile('morse_decoded.txt', text, 'text/plain'); + } + + function exportCsv() { + var lines = ['timestamp,morse,character']; + state.decodedLog.forEach(function (e) { + lines.push(e.timestamp + ',"' + e.morse + '",' + e.char); + }); + downloadFile('morse_decoded.csv', lines.join('\n'), 'text/csv'); + } + + function copyToClipboard() { + var text = state.decodedLog.map(function (e) { return e.char; }).join(''); + navigator.clipboard.writeText(text).then(function () { + var btn = document.getElementById('morseCopyBtn'); + if (btn) { + var orig = btn.textContent; + btn.textContent = 'Copied!'; + setTimeout(function () { btn.textContent = orig; }, 1500); + } + }); + } + + function downloadFile(filename, content, type) { + var blob = new Blob([content], { type: type }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + } + + // ---- UI ---- + + function updateUI(running) { + var startBtn = document.getElementById('morseStartBtn'); + var stopBtn = document.getElementById('morseStopBtn'); + var indicator = document.getElementById('morseStatusIndicator'); + var statusText = document.getElementById('morseStatusText'); + + if (startBtn) startBtn.style.display = running ? 'none' : 'block'; + if (stopBtn) stopBtn.style.display = running ? 'block' : 'none'; + + if (indicator) { + indicator.style.background = running ? '#00ff88' : 'var(--text-dim)'; + } + if (statusText) { + statusText.textContent = running ? 'Listening' : 'Standby'; + } + } + + function setFreq(mhz) { + var el = document.getElementById('morseFrequency'); + if (el) el.value = mhz; + } + + // ---- Public API ---- + + return { + init: init, + destroy: destroy, + start: start, + stop: stop, + setFreq: setFreq, + exportTxt: exportTxt, + exportCsv: exportCsv, + copyToClipboard: copyToClipboard, + clearText: clearDecodedText, + }; +})(); diff --git a/templates/index.html b/templates/index.html index fe780f7..bd65eb3 100644 --- a/templates/index.html +++ b/templates/index.html @@ -80,7 +80,8 @@ subghz: "{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9", bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4", spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}", - wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}" + wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}", + morse: "{{ url_for('static', filename='css/modes/morse.css') }}" }; window.INTERCEPT_MODE_STYLE_LOADED = {}; window.INTERCEPT_MODE_STYLE_PROMISES = {}; @@ -271,6 +272,10 @@ Waterfall + @@ -675,6 +680,8 @@ {% include 'partials/modes/wefax.html' %} + {% include 'partials/modes/morse.html' %} + {% include 'partials/modes/space-weather.html' %} {% include 'partials/modes/tscm.html' %} @@ -3001,6 +3008,25 @@ + +
++ Decode CW (continuous wave) Morse code from amateur radio HF bands using USB demodulation + and Goertzel tone detection. +
++ CW operates on HF bands (1-30 MHz). Requires an HF-capable SDR with direct sampling + or an upconverter, plus an appropriate HF antenna (dipole, end-fed, or random wire). +
+