Use PTY for SatDump output capture instead of pipe

SatDump writes to stderr via fwrite() with its custom logger. When
stderr is redirected to a pipe, C runtime fully buffers it. Neither
stdbuf nor bufsize settings help since SatDump doesn't use stdio for
output.

PTY (pseudo-terminal) makes SatDump think it's writing to a real
terminal, which disables buffering. Also strips ANSI escape codes
from the output and properly handles \r progress lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mitch Ross
2026-02-07 15:17:03 -05:00
parent b87623cf66
commit f9786aa75a
+100 -44
View File
@@ -14,8 +14,11 @@ rtl_fm capture for manual decoding when SatDump is unavailable.
from __future__ import annotations from __future__ import annotations
import io
import os import os
import pty
import re import re
import select
import shutil import shutil
import subprocess import subprocess
import threading import threading
@@ -147,6 +150,7 @@ class WeatherSatDecoder:
self._images: list[WeatherSatImage] = [] self._images: list[WeatherSatImage] = []
self._reader_thread: threading.Thread | None = None self._reader_thread: threading.Thread | None = None
self._watcher_thread: threading.Thread | None = None self._watcher_thread: threading.Thread | None = None
self._pty_master_fd: int | None = None
self._current_satellite: str = '' self._current_satellite: str = ''
self._current_frequency: float = 0.0 self._current_frequency: float = 0.0
self._current_mode: str = '' self._current_mode: str = ''
@@ -297,7 +301,7 @@ class WeatherSatDecoder:
# Auto-detect serial by querying rtl_eeprom, fall back to string index. # Auto-detect serial by querying rtl_eeprom, fall back to string index.
source_id = self._resolve_device_id(device_index) source_id = self._resolve_device_id(device_index)
satdump_cmd = [ cmd = [
'satdump', 'live', 'satdump', 'live',
sat_info['pipeline'], sat_info['pipeline'],
str(self._capture_output_dir), str(self._capture_output_dir),
@@ -308,43 +312,52 @@ class WeatherSatDecoder:
'--source_id', source_id, '--source_id', source_id,
] ]
# Wrap with stdbuf to disable output buffering.
# SatDump (C++) fully buffers stdout when writing to a pipe,
# which prevents our reader from seeing any output until exit.
if shutil.which('stdbuf'):
cmd = ['stdbuf', '-o0', '-e0'] + satdump_cmd
else:
cmd = satdump_cmd
if bias_t: if bias_t:
cmd.append('--bias') cmd.append('--bias')
logger.info(f"Starting SatDump: {' '.join(cmd)}") logger.info(f"Starting SatDump: {' '.join(cmd)}")
# Use a pseudo-terminal so SatDump thinks it's writing to a real
# terminal. C/C++ runtimes disable buffering on TTYs, which lets
# us see output (including \r progress lines) in real time.
master_fd, slave_fd = pty.openpty()
self._pty_master_fd = master_fd
self._process = subprocess.Popen( self._process = subprocess.Popen(
cmd, cmd,
stdout=subprocess.PIPE, stdout=slave_fd,
stderr=subprocess.STDOUT, stderr=slave_fd,
text=True, stdin=subprocess.DEVNULL,
bufsize=1, # line-buffered close_fds=True,
env={**os.environ, 'PYTHONUNBUFFERED': '1'},
) )
os.close(slave_fd) # parent doesn't need the slave side
# Check for early exit (SatDump errors out immediately) # Check for early exit (SatDump errors out immediately)
try: try:
retcode = self._process.wait(timeout=3) retcode = self._process.wait(timeout=3)
# Process already died — read whatever output it produced # Process already died — read whatever output it produced
output = '' output = b''
if self._process.stdout: try:
output = self._process.stdout.read() while True:
r, _, _ = select.select([master_fd], [], [], 0.1)
if not r:
break
chunk = os.read(master_fd, 4096)
if not chunk:
break
output += chunk
except OSError:
pass
os.close(master_fd)
self._pty_master_fd = None
output_str = output.decode('utf-8', errors='replace')
error_msg = f"SatDump exited immediately (code {retcode})" error_msg = f"SatDump exited immediately (code {retcode})"
if output: if output_str:
# Extract the most useful error line for line in output_str.strip().splitlines():
for line in output.strip().splitlines():
if 'error' in line.lower() or 'could not' in line.lower() or 'cannot' in line.lower(): if 'error' in line.lower() or 'could not' in line.lower() or 'cannot' in line.lower():
error_msg = line.strip() error_msg = line.strip()
break break
logger.error(f"SatDump output:\n{output}") logger.error(f"SatDump output:\n{output_str}")
self._process = None self._process = None
raise RuntimeError(error_msg) raise RuntimeError(error_msg)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
@@ -416,39 +429,67 @@ class WeatherSatDecoder:
# Fall back to string index # Fall back to string index
return str(device_index) return str(device_index)
@staticmethod def _read_pty_lines(self):
def _read_lines(stream): """Read lines from the PTY master fd, splitting on \\n and \\r.
"""Read lines from stream, splitting on both \\n and \\r.
SatDump uses \\r carriage returns for progress updates that overwrite SatDump uses \\r carriage returns for progress updates. A PTY gives
the same terminal line. Python's readline() only splits on \\n, so us unbuffered output. We use select() to detect data availability
those updates never arrive. This reads char-by-char and yields and os.read() for raw bytes, then split on line boundaries.
complete lines on either delimiter.
""" """
buf = [] master_fd = self._pty_master_fd
while True: if master_fd is None:
ch = stream.read(1) return
if not ch:
# EOF buf = b''
if buf: while self._running:
yield ''.join(buf) try:
return r, _, _ = select.select([master_fd], [], [], 1.0)
if ch in ('\n', '\r'): if not r:
if buf: # Timeout — check if process is still alive
yield ''.join(buf) if self._process and self._process.poll() is not None:
buf = [] break
else: continue
buf.append(ch) chunk = os.read(master_fd, 4096)
if not chunk:
break
buf += chunk
# Split on \r and \n
while b'\n' in buf or b'\r' in buf:
# Find earliest delimiter
idx_n = buf.find(b'\n')
idx_r = buf.find(b'\r')
if idx_n == -1:
idx = idx_r
elif idx_r == -1:
idx = idx_n
else:
idx = min(idx_n, idx_r)
line = buf[:idx]
buf = buf[idx + 1:]
# Skip empty lines
text = line.decode('utf-8', errors='replace').strip()
# Strip ANSI escape codes that terminals produce
text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
if text:
yield text
except OSError:
break
# Drain remaining buffer
text = buf.decode('utf-8', errors='replace').strip()
if text:
text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
if text:
yield text
def _read_satdump_output(self) -> None: def _read_satdump_output(self) -> None:
"""Read SatDump stdout/stderr for progress updates.""" """Read SatDump stdout/stderr for progress updates."""
if not self._process or not self._process.stdout: if not self._process or self._pty_master_fd is None:
return return
last_emit_time = 0.0 last_emit_time = 0.0
try: try:
for line in self._read_lines(self._process.stdout): for line in self._read_pty_lines():
if not self._running: if not self._running:
break break
@@ -544,6 +585,14 @@ class WeatherSatDecoder:
except Exception as e: except Exception as e:
logger.error(f"Error reading SatDump output: {e}") logger.error(f"Error reading SatDump output: {e}")
finally: finally:
# Close PTY master fd
if self._pty_master_fd is not None:
try:
os.close(self._pty_master_fd)
except OSError:
pass
self._pty_master_fd = None
# Process ended — release resources # Process ended — release resources
was_running = self._running was_running = self._running
self._running = False self._running = False
@@ -670,6 +719,13 @@ class WeatherSatDecoder:
with self._lock: with self._lock:
self._running = False self._running = False
if self._pty_master_fd is not None:
try:
os.close(self._pty_master_fd)
except OSError:
pass
self._pty_master_fd = None
if self._process: if self._process:
try: try:
self._process.terminate() self._process.terminate()