From 3a962ca2074329ebb1ff7fc2ba7ec9d79ede8d03 Mon Sep 17 00:00:00 2001 From: Smittix Date: Wed, 18 Feb 2026 21:12:12 +0000 Subject: [PATCH] fix: SSTV VIS detector stuck in DETECTED state on validation failure The previous fix (f29ae3d) introduced a regression: when VIS parity check failed or the VIS code was unrecognized, the detector entered DETECTED state permanently and never resumed scanning. Now it resets to IDLE on validation failure and only enters DETECTED on success. Also resets partial image progress counter between consecutive decodes and adds SDR device claiming to general SSTV route to prevent conflicts. Co-Authored-By: Claude Opus 4.6 --- routes/sstv_general.py | 23 +++++++++++++++++++++++ utils/sstv/sstv_decoder.py | 1 + utils/sstv/vis.py | 30 ++++++++++++++++++++---------- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/routes/sstv_general.py b/routes/sstv_general.py index 0ddcbfb..63d4f8d 100644 --- a/routes/sstv_general.py +++ b/routes/sstv_general.py @@ -13,6 +13,7 @@ from pathlib import Path from flask import Blueprint, Response, jsonify, request, send_file +import app as app_module from utils.logging import get_logger from utils.sse import format_sse from utils.event_pipeline import process_event @@ -27,6 +28,9 @@ sstv_general_bp = Blueprint('sstv_general', __name__, url_prefix='/sstv-general' # Queue for SSE progress streaming _sstv_general_queue: queue.Queue = queue.Queue(maxsize=100) +# Track which device is being used +_sstv_general_active_device: int | None = None + # Predefined SSTV frequencies SSTV_FREQUENCIES = [ {'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'}, @@ -150,6 +154,17 @@ def start_decoder(): 'message': 'Modulation must be fm, usb, or lsb', }), 400 + # Claim SDR device + global _sstv_general_active_device + device_int = int(device_index) + error = app_module.claim_sdr_device(device_int, 'sstv_general') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error, + }), 409 + # Set callback and start decoder.set_callback(_progress_callback) success = decoder.start( @@ -159,6 +174,7 @@ def start_decoder(): ) if success: + _sstv_general_active_device = device_int return jsonify({ 'status': 'started', 'frequency': frequency, @@ -166,6 +182,7 @@ def start_decoder(): 'device': device_index, }) else: + app_module.release_sdr_device(device_int) return jsonify({ 'status': 'error', 'message': 'Failed to start decoder', @@ -175,8 +192,14 @@ def start_decoder(): @sstv_general_bp.route('/stop', methods=['POST']) def stop_decoder(): """Stop general SSTV decoder.""" + global _sstv_general_active_device decoder = get_general_sstv_decoder() decoder.stop() + + if _sstv_general_active_device is not None: + app_module.release_sdr_device(_sstv_general_active_device) + _sstv_general_active_device = None + return jsonify({'status': 'stopped'}) diff --git a/utils/sstv/sstv_decoder.py b/utils/sstv/sstv_decoder.py index 498d52e..20f3eba 100644 --- a/utils/sstv/sstv_decoder.py +++ b/utils/sstv/sstv_decoder.py @@ -475,6 +475,7 @@ class SSTVDecoder: mode_spec = get_mode(vis_code) if mode_spec: current_mode_name = mode_name + last_partial_pct = -1 image_decoder = SSTVImageDecoder( mode_spec, sample_rate=SAMPLE_RATE, diff --git a/utils/sstv/vis.py b/utils/sstv/vis.py index 1f014e4..50a83c2 100644 --- a/utils/sstv/vis.py +++ b/utils/sstv/vis.py @@ -298,16 +298,26 @@ class VISDetector: if self._tone_counter >= self._bit_windows: result = self._validate_and_decode() - # Do NOT call reset() here. self._buffer still holds samples - # that arrived after the STOP_BIT window — those are the very - # first samples of the image. Wiping the buffer here causes all - # of them to be lost, making the image decoder start mid-stream - # and producing garbled/diagonal output. - # feed() will advance past the current window, leaving - # self._buffer pointing at the image start. The caller must - # read remaining_buffer and then call reset() explicitly. - self._state = VISState.DETECTED - return result + if result is not None: + # Do NOT call reset() here. self._buffer still holds + # samples that arrived after the STOP_BIT window — those + # are the very first samples of the image. Wiping the + # buffer here causes all of them to be lost, making the + # image decoder start mid-stream and producing + # garbled/diagonal output. + # feed() will advance past the current window, leaving + # self._buffer pointing at the image start. The caller + # must read remaining_buffer and then call reset(). + self._state = VISState.DETECTED + return result + else: + # Parity failure or unknown VIS code — reset and + # continue scanning for the next VIS header. + self._tone_counter = 0 + self._data_bits = [] + self._parity_bit = 0 + self._bit_accumulator = [] + self._state = VISState.IDLE elif self._state == VISState.DETECTED: # Waiting for caller to call reset() after reading remaining_buffer.