mirror of
https://github.com/smittix/intercept.git
synced 2026-07-26 01:38:10 -07:00
Add HackRF support to TSCM RF scan and misc improvements
TSCM RF scan now auto-detects HackRF via SDRFactory and uses hackrf_sweep as an alternative to rtl_power. Also includes improvements to listening post, rtlamr, weather satellite, SubGHz, Meshtastic, SSTV, WeFax, and process monitor modules. Fixes #154 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+105
-94
@@ -173,7 +173,7 @@ class WeatherSatDecoder:
|
||||
self._current_frequency: float = 0.0
|
||||
self._current_mode: str = ''
|
||||
self._capture_start_time: float = 0
|
||||
self._device_index: int = -1
|
||||
self._device_index: int = -1
|
||||
self._capture_output_dir: Path | None = None
|
||||
self._on_complete_callback: Callable[[], None] | None = None
|
||||
self._capture_phase: str = 'idle'
|
||||
@@ -303,13 +303,13 @@ class WeatherSatDecoder:
|
||||
))
|
||||
return False
|
||||
|
||||
self._current_satellite = satellite
|
||||
self._current_frequency = sat_info['frequency']
|
||||
self._current_mode = sat_info['mode']
|
||||
self._device_index = -1 # Offline decode does not claim an SDR device
|
||||
self._capture_start_time = time.time()
|
||||
self._capture_phase = 'decoding'
|
||||
self._stop_event.clear()
|
||||
self._current_satellite = satellite
|
||||
self._current_frequency = sat_info['frequency']
|
||||
self._current_mode = sat_info['mode']
|
||||
self._device_index = -1 # Offline decode does not claim an SDR device
|
||||
self._capture_start_time = time.time()
|
||||
self._capture_phase = 'decoding'
|
||||
self._stop_event.clear()
|
||||
|
||||
try:
|
||||
self._running = True
|
||||
@@ -363,6 +363,20 @@ class WeatherSatDecoder:
|
||||
Returns:
|
||||
True if started successfully
|
||||
"""
|
||||
# Validate satellite BEFORE acquiring the lock
|
||||
sat_info = WEATHER_SATELLITES.get(satellite)
|
||||
if not sat_info:
|
||||
logger.error(f"Unknown satellite: {satellite}")
|
||||
self._emit_progress(CaptureProgress(
|
||||
status='error',
|
||||
message=f'Unknown satellite: {satellite}'
|
||||
))
|
||||
return False
|
||||
|
||||
# Resolve device ID BEFORE lock — this runs rtl_test which can
|
||||
# take up to 5s and has no side effects on instance state.
|
||||
source_id = self._resolve_device_id(device_index)
|
||||
|
||||
with self._lock:
|
||||
if self._running:
|
||||
return True
|
||||
@@ -375,15 +389,6 @@ class WeatherSatDecoder:
|
||||
))
|
||||
return False
|
||||
|
||||
sat_info = WEATHER_SATELLITES.get(satellite)
|
||||
if not sat_info:
|
||||
logger.error(f"Unknown satellite: {satellite}")
|
||||
self._emit_progress(CaptureProgress(
|
||||
status='error',
|
||||
message=f'Unknown satellite: {satellite}'
|
||||
))
|
||||
return False
|
||||
|
||||
self._current_satellite = satellite
|
||||
self._current_frequency = sat_info['frequency']
|
||||
self._current_mode = sat_info['mode']
|
||||
@@ -394,7 +399,7 @@ class WeatherSatDecoder:
|
||||
|
||||
try:
|
||||
self._running = True
|
||||
self._start_satdump(sat_info, device_index, gain, sample_rate, bias_t)
|
||||
self._start_satdump(sat_info, device_index, gain, sample_rate, bias_t, source_id)
|
||||
|
||||
logger.info(
|
||||
f"Weather satellite capture started: {satellite} "
|
||||
@@ -429,6 +434,7 @@ class WeatherSatDecoder:
|
||||
gain: float,
|
||||
sample_rate: int,
|
||||
bias_t: bool,
|
||||
source_id: str | None = None,
|
||||
) -> None:
|
||||
"""Start SatDump live capture and decode."""
|
||||
# Create timestamped output directory for this capture
|
||||
@@ -439,9 +445,9 @@ class WeatherSatDecoder:
|
||||
|
||||
freq_hz = int(sat_info['frequency'] * 1_000_000)
|
||||
|
||||
# SatDump v1.2+ uses string source_id (device serial) not numeric index.
|
||||
# Auto-detect serial by querying rtl_eeprom, fall back to string index.
|
||||
source_id = self._resolve_device_id(device_index)
|
||||
# Use pre-resolved source_id, or fall back to resolving now
|
||||
if source_id is None:
|
||||
source_id = self._resolve_device_id(device_index)
|
||||
|
||||
cmd = [
|
||||
'satdump', 'live',
|
||||
@@ -465,18 +471,18 @@ class WeatherSatDecoder:
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
self._pty_master_fd = master_fd
|
||||
|
||||
self._process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
)
|
||||
register_process(self._process)
|
||||
try:
|
||||
os.close(slave_fd) # parent doesn't need the slave side
|
||||
except OSError:
|
||||
pass
|
||||
self._process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
)
|
||||
register_process(self._process)
|
||||
try:
|
||||
os.close(slave_fd) # parent doesn't need the slave side
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Check for early exit asynchronously (avoid blocking /start for 3s)
|
||||
def _check_early_exit():
|
||||
@@ -568,18 +574,18 @@ class WeatherSatDecoder:
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
self._pty_master_fd = master_fd
|
||||
|
||||
self._process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
)
|
||||
register_process(self._process)
|
||||
try:
|
||||
os.close(slave_fd) # parent doesn't need the slave side
|
||||
except OSError:
|
||||
pass
|
||||
self._process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
)
|
||||
register_process(self._process)
|
||||
try:
|
||||
os.close(slave_fd) # parent doesn't need the slave side
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# For offline mode, don't check for early exit — file decoding
|
||||
# may complete very quickly and exit code 0 is normal success.
|
||||
@@ -812,20 +818,23 @@ class WeatherSatDecoder:
|
||||
# Signal watcher thread to do final scan and exit
|
||||
self._stop_event.set()
|
||||
|
||||
# Process ended — release resources
|
||||
was_running = self._running
|
||||
self._running = False
|
||||
# Acquire lock when modifying shared state to avoid racing
|
||||
# with stop() which may have already cleaned up.
|
||||
with self._lock:
|
||||
was_running = self._running
|
||||
self._running = False
|
||||
process = self._process
|
||||
elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0
|
||||
|
||||
if was_running:
|
||||
# Collect exit status (returncode is only set after poll/wait)
|
||||
if self._process and self._process.returncode is None:
|
||||
if process and process.returncode is None:
|
||||
try:
|
||||
self._process.wait(timeout=5)
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._process.kill()
|
||||
self._process.wait()
|
||||
retcode = self._process.returncode if self._process else None
|
||||
process.kill()
|
||||
process.wait()
|
||||
retcode = process.returncode if process else None
|
||||
if retcode and retcode != 0:
|
||||
self._capture_phase = 'error'
|
||||
self._emit_progress(CaptureProgress(
|
||||
@@ -899,24 +908,24 @@ class WeatherSatDecoder:
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Determine product type from filename/path
|
||||
product = self._parse_product_name(filepath)
|
||||
|
||||
# Copy image to main output dir for serving
|
||||
safe_sat = re.sub(r'[^A-Za-z0-9_-]+', '_', self._current_satellite).strip('_') or 'satellite'
|
||||
safe_stem = re.sub(r'[^A-Za-z0-9_-]+', '_', filepath.stem).strip('_') or 'image'
|
||||
suffix = filepath.suffix.lower()
|
||||
if suffix not in ('.png', '.jpg', '.jpeg'):
|
||||
suffix = '.png'
|
||||
serve_name = (
|
||||
f"{safe_sat}_{safe_stem}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}"
|
||||
f"{suffix}"
|
||||
)
|
||||
serve_path = self._output_dir / serve_name
|
||||
try:
|
||||
shutil.copy2(filepath, serve_path)
|
||||
except OSError:
|
||||
# Copy failed — don't mark as known so it can be retried
|
||||
# Determine product type from filename/path
|
||||
product = self._parse_product_name(filepath)
|
||||
|
||||
# Copy image to main output dir for serving
|
||||
safe_sat = re.sub(r'[^A-Za-z0-9_-]+', '_', self._current_satellite).strip('_') or 'satellite'
|
||||
safe_stem = re.sub(r'[^A-Za-z0-9_-]+', '_', filepath.stem).strip('_') or 'image'
|
||||
suffix = filepath.suffix.lower()
|
||||
if suffix not in ('.png', '.jpg', '.jpeg'):
|
||||
suffix = '.png'
|
||||
serve_name = (
|
||||
f"{safe_sat}_{safe_stem}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}"
|
||||
f"{suffix}"
|
||||
)
|
||||
serve_path = self._output_dir / serve_name
|
||||
try:
|
||||
shutil.copy2(filepath, serve_path)
|
||||
except OSError:
|
||||
# Copy failed — don't mark as known so it can be retried
|
||||
continue
|
||||
|
||||
# Only mark as known after successful copy
|
||||
@@ -960,12 +969,12 @@ class WeatherSatDecoder:
|
||||
return 'Multispectral Analysis'
|
||||
if 'thermal' in name or 'temp' in name:
|
||||
return 'Thermal'
|
||||
if 'ndvi' in name:
|
||||
return 'NDVI Vegetation'
|
||||
if 'channel' in name or 'ch' in name:
|
||||
match = re.search(r'(?:channel|ch)[\s_-]*(\d+)', name)
|
||||
if match:
|
||||
return f'Channel {match.group(1)}'
|
||||
if 'ndvi' in name:
|
||||
return 'NDVI Vegetation'
|
||||
if 'channel' in name or 'ch' in name:
|
||||
match = re.search(r'(?:channel|ch)[\s_-]*(\d+)', name)
|
||||
if match:
|
||||
return f'Channel {match.group(1)}'
|
||||
if 'avhrr' in name:
|
||||
return 'AVHRR'
|
||||
if 'msu' in name or 'mtvza' in name:
|
||||
@@ -986,14 +995,16 @@ class WeatherSatDecoder:
|
||||
self._running = False
|
||||
self._stop_event.set()
|
||||
self._close_pty()
|
||||
process = self._process
|
||||
self._process = None
|
||||
elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0
|
||||
logger.info(f"Weather satellite capture stopped after {elapsed}s")
|
||||
self._device_index = -1
|
||||
|
||||
if self._process:
|
||||
safe_terminate(self._process)
|
||||
self._process = None
|
||||
|
||||
elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0
|
||||
logger.info(f"Weather satellite capture stopped after {elapsed}s")
|
||||
self._device_index = -1
|
||||
# Terminate outside the lock so stop() returns quickly
|
||||
# and doesn't block start() or other lock acquisitions
|
||||
if process:
|
||||
safe_terminate(process)
|
||||
|
||||
def get_images(self) -> list[WeatherSatImage]:
|
||||
"""Get list of decoded images."""
|
||||
@@ -1029,18 +1040,18 @@ class WeatherSatDecoder:
|
||||
|
||||
sat_info = WEATHER_SATELLITES.get(satellite, {})
|
||||
|
||||
image = WeatherSatImage(
|
||||
filename=filepath.name,
|
||||
path=filepath,
|
||||
satellite=satellite,
|
||||
mode=sat_info.get('mode', 'Unknown'),
|
||||
image = WeatherSatImage(
|
||||
filename=filepath.name,
|
||||
path=filepath,
|
||||
satellite=satellite,
|
||||
mode=sat_info.get('mode', 'Unknown'),
|
||||
timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||
frequency=sat_info.get('frequency', 0.0),
|
||||
size_bytes=stat.st_size,
|
||||
product=self._parse_product_name(filepath),
|
||||
)
|
||||
self._images.append(image)
|
||||
known_filenames.add(filepath.name)
|
||||
size_bytes=stat.st_size,
|
||||
product=self._parse_product_name(filepath),
|
||||
)
|
||||
self._images.append(image)
|
||||
known_filenames.add(filepath.name)
|
||||
|
||||
def delete_image(self, filename: str) -> bool:
|
||||
"""Delete a decoded image."""
|
||||
|
||||
Reference in New Issue
Block a user