Fix Morse decoder scope events not reaching frontend

Replace blocking rtl_stdout.read() with select()+os.read() so the
decoder thread emits diagnostic heartbeat scope events when rtl_fm
produces no PCM data (common in direct sampling mode). Add waiting-state
rendering in the scope canvas and hide the generic placeholder/status
bar for morse mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-26 09:30:58 +00:00
parent 2eea28da05
commit c0c066904c
4 changed files with 79 additions and 3 deletions

View File

@@ -22,6 +22,7 @@ var MorseMode = (function () {
var SCOPE_HISTORY_LEN = 300;
var scopeThreshold = 0;
var scopeToneOn = false;
var scopeWaiting = false;
// ---- Initialization ----
@@ -150,6 +151,11 @@ var MorseMode = (function () {
if (type === 'scope') {
// Update scope data
var amps = msg.amplitudes || [];
if (msg.waiting && amps.length === 0 && scopeHistory.length === 0) {
scopeWaiting = true;
} else if (amps.length > 0) {
scopeWaiting = false;
}
for (var i = 0; i < amps.length; i++) {
scopeHistory.push(amps[i]);
if (scopeHistory.length > SCOPE_HISTORY_LEN) {
@@ -238,6 +244,7 @@ var MorseMode = (function () {
scopeCtx = canvas.getContext('2d');
scopeCtx.scale(dpr, dpr);
scopeHistory = [];
scopeWaiting = false;
var toneLabel = document.getElementById('morseScopeToneLabel');
var threshLabel = document.getElementById('morseScopeThreshLabel');
@@ -255,6 +262,13 @@ var MorseMode = (function () {
if (threshLabel) threshLabel.textContent = scopeThreshold > 0 ? Math.round(scopeThreshold) : '--';
if (scopeHistory.length === 0) {
if (scopeWaiting) {
scopeCtx.fillStyle = '#556677';
scopeCtx.font = '12px monospace';
scopeCtx.textAlign = 'center';
scopeCtx.fillText('Awaiting SDR data\u2026', w / 2, h / 2);
scopeCtx.textAlign = 'start';
}
scopeAnim = requestAnimationFrame(draw);
return;
}

View File

@@ -4256,8 +4256,8 @@
// Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar');
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') ? 'none' : 'flex';
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'morse') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'morse') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
if (mode !== 'meshtastic') {

View File

@@ -327,6 +327,48 @@ class TestMorseDecoderThread:
t.join(timeout=5)
assert not t.is_alive(), "Thread should finish after reading all data"
def test_thread_heartbeat_on_no_data(self):
"""When rtl_fm produces no data, thread should emit waiting scope events."""
import os as _os
stop = threading.Event()
q = queue.Queue(maxsize=100)
# Create a pipe that never gets written to (simulates rtl_fm with no output)
read_fd, write_fd = _os.pipe()
read_file = _os.fdopen(read_fd, 'rb', 0)
t = threading.Thread(
target=morse_decoder_thread,
args=(read_file, q, stop),
)
t.daemon = True
t.start()
# Wait up to 5 seconds for at least one heartbeat event
events = []
import time as _time
deadline = _time.monotonic() + 5.0
while _time.monotonic() < deadline:
try:
ev = q.get(timeout=0.5)
events.append(ev)
if ev.get('waiting'):
break
except queue.Empty:
continue
stop.set()
_os.close(write_fd)
read_file.close()
t.join(timeout=3)
waiting_events = [e for e in events if e.get('type') == 'scope' and e.get('waiting')]
assert len(waiting_events) >= 1, f"Expected waiting heartbeat events, got {events}"
ev = waiting_events[0]
assert ev['amplitudes'] == []
assert ev['threshold'] == 0
assert ev['tone_on'] is False
def test_thread_produces_events(self):
"""Thread should push character events to the queue."""
import io

View File

@@ -7,7 +7,9 @@ from __future__ import annotations
import contextlib
import math
import os
import queue
import select
import struct
import threading
import time
@@ -284,8 +286,26 @@ def morse_decoder_thread(
)
try:
fd = rtl_stdout.fileno()
while not stop_event.is_set():
data = rtl_stdout.read(CHUNK)
ready, _, _ = select.select([fd], [], [], 2.0)
if not ready:
# No data from SDR — emit diagnostic heartbeat
now = time.monotonic()
if now - last_scope >= SCOPE_INTERVAL:
last_scope = now
with contextlib.suppress(queue.Full):
output_queue.put_nowait({
'type': 'scope',
'amplitudes': [],
'threshold': 0,
'tone_on': False,
'waiting': True,
})
continue
data = os.read(fd, CHUNK)
if not data:
break