mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 06:01:56 -07:00
Apply pending weather-sat and wefax updates
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
## Workflow Orchestration
|
||||||
|
### 1. Plan Node Default
|
||||||
|
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
|
||||||
|
- If something goes sideways, STOP and re-plan immediately - don't keep pushing
|
||||||
|
- Use plan mode for verification steps, not just building
|
||||||
|
- Write detailed specs upfront to reduce ambiguity
|
||||||
|
### 2. Subagent Strategy
|
||||||
|
- Use subagents liberally to keep main context window clean
|
||||||
|
- Offload research, exploration, and parallel analysis to subagents
|
||||||
|
- For complex problems, throw more compute at it via subagents
|
||||||
|
- One tack per subagent for focused execution
|
||||||
|
### 3. Self-Improvement Loop
|
||||||
|
- After ANY correction from the user: update 'tasks/lessons.md" with the pattern
|
||||||
|
- Write rules for yourself that prevent the same mistake
|
||||||
|
- Ruthlessly iterate on these lessons until mistake rate drops
|
||||||
|
- Review lessons at session start for relevant project
|
||||||
|
### 4. Verification Before Done
|
||||||
|
- Never mark a task complete without proving it works
|
||||||
|
- Diff behavior between main and your changes when relevant
|
||||||
|
- Ask yourself: "Would a staff engineer approve this?"
|
||||||
|
- Run tests, check logs, demonstrate correctness
|
||||||
|
### 5. Demand Elegance (Balanced)
|
||||||
|
- For non-trivial changes: pause and ask "is there a more elegant way?"
|
||||||
|
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
|
||||||
|
- Skip this for simple, obvious fixes - don't over-engineer
|
||||||
|
-Challenge your own work before presenting it
|
||||||
|
### 6. Autonomous Bug Fizing
|
||||||
|
- When given a bug report: just fix it. Don't ask for hand-holding
|
||||||
|
- Point at logs, errors, failing tests - then resolve them
|
||||||
|
- Zero context switching required from the user
|
||||||
|
- Go fix failing CI tests without being told how
|
||||||
|
## Task Management
|
||||||
|
1. **Plan First**: Write plan to "tasks/todo.md" with checkable items
|
||||||
|
2. **Verify Plan**: Check in before starting implementation
|
||||||
|
3. **Track Progress**: Mark items complete as you go
|
||||||
|
4. **Explain Changes**: High-level summary at each step
|
||||||
|
5. **Document Results**: Add review section to 'tasks/todo.md"
|
||||||
|
6. **Capture Lessons**: Update 'tasks/lessons.md' after corrections
|
||||||
|
## Core Principles
|
||||||
|
- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
|
||||||
|
- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
|
||||||
|
- **Minimat Impact**: Changes should only touch what's necessary. Avoid introducing bugs.
|
||||||
+44
-24
@@ -26,19 +26,48 @@ logger = get_logger('intercept.weather_sat')
|
|||||||
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
|
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
|
||||||
|
|
||||||
# Queue for SSE progress streaming
|
# Queue for SSE progress streaming
|
||||||
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
|
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
|
|
||||||
def _progress_callback(progress: CaptureProgress) -> None:
|
def _progress_callback(progress: CaptureProgress) -> None:
|
||||||
"""Callback to queue progress updates for SSE stream."""
|
"""Callback to queue progress updates for SSE stream."""
|
||||||
try:
|
try:
|
||||||
_weather_sat_queue.put_nowait(progress.to_dict())
|
_weather_sat_queue.put_nowait(progress.to_dict())
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
try:
|
try:
|
||||||
_weather_sat_queue.get_nowait()
|
_weather_sat_queue.get_nowait()
|
||||||
_weather_sat_queue.put_nowait(progress.to_dict())
|
_weather_sat_queue.put_nowait(progress.to_dict())
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _release_weather_sat_device(device_index: int) -> None:
|
||||||
|
"""Release an SDR device only if weather-sat currently owns it."""
|
||||||
|
if device_index < 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import app as app_module
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
|
||||||
|
owner = None
|
||||||
|
get_status = getattr(app_module, 'get_sdr_device_status', None)
|
||||||
|
if callable(get_status):
|
||||||
|
try:
|
||||||
|
owner = get_status().get(device_index)
|
||||||
|
except Exception:
|
||||||
|
owner = None
|
||||||
|
|
||||||
|
if owner and owner != 'weather_sat':
|
||||||
|
logger.debug(
|
||||||
|
'Skipping SDR release for device %s owned by %s',
|
||||||
|
device_index,
|
||||||
|
owner,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
app_module.release_sdr_device(device_index)
|
||||||
|
|
||||||
|
|
||||||
@weather_sat_bp.route('/status')
|
@weather_sat_bp.route('/status')
|
||||||
@@ -149,15 +178,11 @@ def start_capture():
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Set callback and on-complete handler for SDR release
|
# Set callback and on-complete handler for SDR release
|
||||||
decoder.set_callback(_progress_callback)
|
decoder.set_callback(_progress_callback)
|
||||||
|
|
||||||
def _release_device():
|
def _release_device():
|
||||||
try:
|
_release_weather_sat_device(device_index)
|
||||||
import app as app_module
|
|
||||||
app_module.release_sdr_device(device_index)
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
decoder.set_on_complete(_release_device)
|
decoder.set_on_complete(_release_device)
|
||||||
|
|
||||||
@@ -318,14 +343,9 @@ def stop_capture():
|
|||||||
decoder = get_weather_sat_decoder()
|
decoder = get_weather_sat_decoder()
|
||||||
device_index = decoder.device_index
|
device_index = decoder.device_index
|
||||||
|
|
||||||
decoder.stop()
|
decoder.stop()
|
||||||
|
|
||||||
# Release SDR device
|
_release_weather_sat_device(device_index)
|
||||||
try:
|
|
||||||
import app as app_module
|
|
||||||
app_module.release_sdr_device(device_index)
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|||||||
+23
-10
@@ -28,16 +28,29 @@ _wefax_queue: queue.Queue = queue.Queue(maxsize=100)
|
|||||||
wefax_active_device: int | None = None
|
wefax_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def _progress_callback(data: dict) -> None:
|
def _progress_callback(data: dict) -> None:
|
||||||
"""Callback to queue progress updates for SSE stream."""
|
"""Callback to queue progress updates for SSE stream."""
|
||||||
try:
|
global wefax_active_device
|
||||||
_wefax_queue.put_nowait(data)
|
|
||||||
except queue.Full:
|
try:
|
||||||
try:
|
_wefax_queue.put_nowait(data)
|
||||||
_wefax_queue.get_nowait()
|
except queue.Full:
|
||||||
_wefax_queue.put_nowait(data)
|
try:
|
||||||
except queue.Empty:
|
_wefax_queue.get_nowait()
|
||||||
pass
|
_wefax_queue.put_nowait(data)
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Ensure manually claimed SDR devices are always released when a
|
||||||
|
# decode session ends on its own (complete/error/stopped).
|
||||||
|
if (
|
||||||
|
isinstance(data, dict)
|
||||||
|
and data.get('type') == 'wefax_progress'
|
||||||
|
and data.get('status') in ('complete', 'error', 'stopped')
|
||||||
|
and wefax_active_device is not None
|
||||||
|
):
|
||||||
|
app_module.release_sdr_device(wefax_active_device)
|
||||||
|
wefax_active_device = None
|
||||||
|
|
||||||
|
|
||||||
@wefax_bp.route('/status')
|
@wefax_bp.route('/status')
|
||||||
|
|||||||
+19
-11
@@ -327,17 +327,25 @@ var WeFax = (function () {
|
|||||||
if (idleEl) idleEl.style.display = 'none';
|
if (idleEl) idleEl.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image complete
|
// Image complete
|
||||||
if (data.status === 'complete' && data.image) {
|
if (data.status === 'complete' && data.image) {
|
||||||
scopeImageBurst = 1.0;
|
scopeImageBurst = 1.0;
|
||||||
loadImages();
|
loadImages();
|
||||||
setStatus('Image decoded: ' + (data.line_count || '?') + ' lines');
|
setStatus('Image decoded: ' + (data.line_count || '?') + ' lines');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.status === 'error') {
|
if (data.status === 'complete') {
|
||||||
state.running = false;
|
state.running = false;
|
||||||
updateButtons(false);
|
updateButtons(false);
|
||||||
showStripError(data.message || 'Decode error');
|
if (!state.schedulerEnabled) {
|
||||||
|
disconnectSSE();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'error') {
|
||||||
|
state.running = false;
|
||||||
|
updateButtons(false);
|
||||||
|
showStripError(data.message || 'Decode error');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.status === 'stopped') {
|
if (data.status === 'stopped') {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Targeted regression tests for recent weather-satellite hardening fixes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from utils.weather_sat import WeatherSatDecoder
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def authed_client(client):
|
||||||
|
"""Return a logged-in test client for authenticated weather-sat routes."""
|
||||||
|
with client.session_transaction() as session:
|
||||||
|
session['logged_in'] = True
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeatherSatRouteReleaseGuards:
|
||||||
|
"""Regression tests for safe SDR release behavior in weather-sat routes."""
|
||||||
|
|
||||||
|
def test_stop_does_not_release_device_owned_by_other_mode(self, authed_client):
|
||||||
|
"""POST /weather-sat/stop should not release a foreign-owned SDR device."""
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.device_index = 2
|
||||||
|
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_decoder', return_value=mock_decoder), \
|
||||||
|
patch('app.get_sdr_device_status', return_value={2: 'wifi'}), \
|
||||||
|
patch('app.release_sdr_device') as mock_release:
|
||||||
|
response = authed_client.post('/weather-sat/stop')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json()['status'] == 'stopped'
|
||||||
|
mock_decoder.stop.assert_called_once()
|
||||||
|
mock_release.assert_not_called()
|
||||||
|
|
||||||
|
def test_stop_releases_device_owned_by_weather_sat(self, authed_client):
|
||||||
|
"""POST /weather-sat/stop should release SDR when weather-sat owns it."""
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.device_index = 2
|
||||||
|
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_decoder', return_value=mock_decoder), \
|
||||||
|
patch('app.get_sdr_device_status', return_value={2: 'weather_sat'}), \
|
||||||
|
patch('app.release_sdr_device') as mock_release:
|
||||||
|
response = authed_client.post('/weather-sat/stop')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json()['status'] == 'stopped'
|
||||||
|
mock_decoder.stop.assert_called_once()
|
||||||
|
mock_release.assert_called_once_with(2)
|
||||||
|
|
||||||
|
def test_stop_skips_release_for_offline_decode_index(self, authed_client):
|
||||||
|
"""POST /weather-sat/stop should not release when decoder index is -1."""
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.device_index = -1
|
||||||
|
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_decoder', return_value=mock_decoder), \
|
||||||
|
patch('app.release_sdr_device') as mock_release:
|
||||||
|
response = authed_client.post('/weather-sat/stop')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json()['status'] == 'stopped'
|
||||||
|
mock_decoder.stop.assert_called_once()
|
||||||
|
mock_release.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeatherSatDecoderRegressions:
|
||||||
|
"""Regression tests for decoder filename and offline-device handling."""
|
||||||
|
|
||||||
|
def test_scan_output_dir_preserves_extension_and_sanitizes_filename(self, tmp_path):
|
||||||
|
"""Copied image names should stay safe and preserve JPG/JPEG extensions."""
|
||||||
|
output_dir = tmp_path / 'weather_sat_out'
|
||||||
|
capture_dir = tmp_path / 'capture'
|
||||||
|
capture_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
source_image = capture_dir / 'channel 3 (raw).jpeg'
|
||||||
|
source_image.write_bytes(b'\xff\xd8\xff' + b'\x00' * 2048)
|
||||||
|
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder(output_dir=output_dir)
|
||||||
|
|
||||||
|
decoder._capture_output_dir = capture_dir
|
||||||
|
decoder._current_satellite = 'METEOR-M2-4'
|
||||||
|
decoder._current_mode = 'LRPT'
|
||||||
|
decoder._current_frequency = 137.9
|
||||||
|
|
||||||
|
decoder._scan_output_dir(set())
|
||||||
|
|
||||||
|
assert len(decoder._images) == 1
|
||||||
|
image = decoder._images[0]
|
||||||
|
assert image.filename.endswith('.jpeg')
|
||||||
|
assert re.fullmatch(r'[A-Za-z0-9_.-]+', image.filename)
|
||||||
|
assert (output_dir / image.filename).is_file()
|
||||||
|
|
||||||
|
def test_start_from_file_keeps_device_index_unclaimed(self, tmp_path):
|
||||||
|
"""Offline file decode should not claim or persist an SDR device index."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'), \
|
||||||
|
patch('pathlib.Path.is_file', return_value=True), \
|
||||||
|
patch('pathlib.Path.resolve') as mock_resolve, \
|
||||||
|
patch.object(WeatherSatDecoder, '_start_satdump_offline') as mock_start:
|
||||||
|
|
||||||
|
resolved = MagicMock()
|
||||||
|
resolved.is_relative_to.return_value = True
|
||||||
|
mock_resolve.return_value = resolved
|
||||||
|
|
||||||
|
decoder = WeatherSatDecoder(output_dir=tmp_path / 'weather_sat_out')
|
||||||
|
success = decoder.start_from_file(
|
||||||
|
satellite='METEOR-M2-3',
|
||||||
|
input_file='data/weather_sat/samples/sample.wav',
|
||||||
|
sample_rate=1_000_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert decoder.device_index == -1
|
||||||
|
mock_start.assert_called_once()
|
||||||
|
|
||||||
|
decoder.stop()
|
||||||
|
assert decoder.device_index == -1
|
||||||
+52
-10
@@ -225,7 +225,7 @@ class TestWeFaxDecoder:
|
|||||||
# Route tests
|
# Route tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestWeFaxRoutes:
|
class TestWeFaxRoutes:
|
||||||
"""WeFax route endpoint tests."""
|
"""WeFax route endpoint tests."""
|
||||||
|
|
||||||
def test_status(self, client):
|
def test_status(self, client):
|
||||||
@@ -419,12 +419,54 @@ class TestWeFaxRoutes:
|
|||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
def test_delete_image_wrong_extension(self, client):
|
def test_delete_image_wrong_extension(self, client):
|
||||||
"""DELETE /wefax/images/<filename> should reject non-PNG."""
|
"""DELETE /wefax/images/<filename> should reject non-PNG."""
|
||||||
_login_session(client)
|
_login_session(client)
|
||||||
mock_decoder = MagicMock()
|
mock_decoder = MagicMock()
|
||||||
|
|
||||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||||
response = client.delete('/wefax/images/test.jpg')
|
response = client.delete('/wefax/images/test.jpg')
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeFaxProgressCallback:
|
||||||
|
"""Regression tests for WeFax route-level progress callback behavior."""
|
||||||
|
|
||||||
|
def test_terminal_progress_releases_active_device(self):
|
||||||
|
"""Terminal decoder events must release any manually claimed SDR."""
|
||||||
|
import routes.wefax as wefax_routes
|
||||||
|
|
||||||
|
original_device = wefax_routes.wefax_active_device
|
||||||
|
try:
|
||||||
|
wefax_routes.wefax_active_device = 3
|
||||||
|
with patch('routes.wefax.app_module.release_sdr_device') as mock_release:
|
||||||
|
wefax_routes._progress_callback({
|
||||||
|
'type': 'wefax_progress',
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'decode failed',
|
||||||
|
})
|
||||||
|
|
||||||
|
mock_release.assert_called_once_with(3)
|
||||||
|
assert wefax_routes.wefax_active_device is None
|
||||||
|
finally:
|
||||||
|
wefax_routes.wefax_active_device = original_device
|
||||||
|
|
||||||
|
def test_non_terminal_progress_does_not_release_active_device(self):
|
||||||
|
"""Non-terminal progress updates must not release SDR ownership."""
|
||||||
|
import routes.wefax as wefax_routes
|
||||||
|
|
||||||
|
original_device = wefax_routes.wefax_active_device
|
||||||
|
try:
|
||||||
|
wefax_routes.wefax_active_device = 4
|
||||||
|
with patch('routes.wefax.app_module.release_sdr_device') as mock_release:
|
||||||
|
wefax_routes._progress_callback({
|
||||||
|
'type': 'wefax_progress',
|
||||||
|
'status': 'receiving',
|
||||||
|
'line_count': 120,
|
||||||
|
})
|
||||||
|
|
||||||
|
mock_release.assert_not_called()
|
||||||
|
assert wefax_routes.wefax_active_device == 4
|
||||||
|
finally:
|
||||||
|
wefax_routes.wefax_active_device = original_device
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Tests for WeFax auto-scheduler behavior and regressions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from utils.wefax_scheduler import ScheduledBroadcast, WeFaxScheduler
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeFaxScheduler:
|
||||||
|
"""WeFaxScheduler regression tests."""
|
||||||
|
|
||||||
|
@patch('threading.Timer')
|
||||||
|
def test_refresh_reschedules_same_utc_slot_next_day(self, mock_timer):
|
||||||
|
"""Completed broadcasts must not block the next day's same UTC slot."""
|
||||||
|
scheduler = WeFaxScheduler()
|
||||||
|
scheduler._enabled = True
|
||||||
|
scheduler._station = 'USCG Kodiak'
|
||||||
|
scheduler._callsign = 'NOJ'
|
||||||
|
scheduler._frequency_khz = 4298.0
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
utc_time = (now - timedelta(hours=2)).strftime('%H:%M')
|
||||||
|
today = now.date().isoformat()
|
||||||
|
|
||||||
|
prior = ScheduledBroadcast(
|
||||||
|
station='USCG Kodiak',
|
||||||
|
callsign='NOJ',
|
||||||
|
frequency_khz=4298.0,
|
||||||
|
utc_time=utc_time,
|
||||||
|
duration_min=20,
|
||||||
|
content='Chart',
|
||||||
|
occurrence_date=today,
|
||||||
|
)
|
||||||
|
prior.status = 'complete'
|
||||||
|
scheduler._broadcasts = [prior]
|
||||||
|
|
||||||
|
mock_timer.return_value = MagicMock()
|
||||||
|
|
||||||
|
with patch('utils.wefax_scheduler.get_station', return_value={
|
||||||
|
'name': 'USCG Kodiak',
|
||||||
|
'schedule': [{
|
||||||
|
'utc': utc_time,
|
||||||
|
'duration_min': 20,
|
||||||
|
'content': 'Chart',
|
||||||
|
}],
|
||||||
|
}):
|
||||||
|
scheduler._refresh_schedule()
|
||||||
|
|
||||||
|
capture_calls = [
|
||||||
|
c for c in mock_timer.call_args_list
|
||||||
|
if len(c.args) >= 2 and getattr(c.args[1], '__name__', '') == '_execute_capture'
|
||||||
|
]
|
||||||
|
assert capture_calls, "Expected a capture timer for the next-day occurrence"
|
||||||
|
|
||||||
|
scheduled = [b for b in scheduler._broadcasts if b.status == 'scheduled']
|
||||||
|
assert len(scheduled) == 1
|
||||||
|
assert scheduled[0].occurrence_date != today
|
||||||
|
|
||||||
|
def test_execute_capture_stops_immediately_if_window_elapsed(self):
|
||||||
|
"""If stop delay computes to <= 0, capture should close out immediately."""
|
||||||
|
scheduler = WeFaxScheduler()
|
||||||
|
scheduler._enabled = True
|
||||||
|
scheduler._callsign = 'NOJ'
|
||||||
|
scheduler._frequency_khz = 4298.0
|
||||||
|
scheduler._device = 0
|
||||||
|
scheduler._gain = 40.0
|
||||||
|
scheduler._ioc = 576
|
||||||
|
scheduler._lpm = 120
|
||||||
|
scheduler._direct_sampling = True
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
sb = ScheduledBroadcast(
|
||||||
|
station='USCG Kodiak',
|
||||||
|
callsign='NOJ',
|
||||||
|
frequency_khz=4298.0,
|
||||||
|
utc_time=now.strftime('%H:%M'),
|
||||||
|
duration_min=0,
|
||||||
|
content='Late chart',
|
||||||
|
occurrence_date=now.date().isoformat(),
|
||||||
|
)
|
||||||
|
sb.status = 'scheduled'
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
mock_decoder.start.return_value = True
|
||||||
|
|
||||||
|
with patch('utils.wefax_scheduler.get_wefax_decoder', return_value=mock_decoder), \
|
||||||
|
patch('utils.wefax_scheduler.WEFAX_CAPTURE_BUFFER_SECONDS', 0), \
|
||||||
|
patch('app.claim_sdr_device', return_value=None), \
|
||||||
|
patch.object(scheduler, '_stop_capture') as mock_stop_capture:
|
||||||
|
scheduler._execute_capture_inner(sb)
|
||||||
|
|
||||||
|
mock_stop_capture.assert_called_once()
|
||||||
|
|
||||||
|
@patch('threading.Timer')
|
||||||
|
def test_terminal_progress_releases_scheduler_device_early(self, mock_timer):
|
||||||
|
"""Scheduler captures must release SDR as soon as terminal progress arrives."""
|
||||||
|
scheduler = WeFaxScheduler()
|
||||||
|
scheduler._enabled = True
|
||||||
|
scheduler._callsign = 'NOJ'
|
||||||
|
scheduler._frequency_khz = 4298.0
|
||||||
|
scheduler._device = 0
|
||||||
|
scheduler._gain = 40.0
|
||||||
|
scheduler._ioc = 576
|
||||||
|
scheduler._lpm = 120
|
||||||
|
scheduler._direct_sampling = True
|
||||||
|
|
||||||
|
sb = ScheduledBroadcast(
|
||||||
|
station='USCG Kodiak',
|
||||||
|
callsign='NOJ',
|
||||||
|
frequency_khz=4298.0,
|
||||||
|
utc_time='12:00',
|
||||||
|
duration_min=20,
|
||||||
|
content='Chart',
|
||||||
|
occurrence_date='2026-01-01',
|
||||||
|
)
|
||||||
|
sb.status = 'scheduled'
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
mock_decoder.start.return_value = True
|
||||||
|
mock_timer.return_value = MagicMock()
|
||||||
|
|
||||||
|
with patch('utils.wefax_scheduler.get_wefax_decoder', return_value=mock_decoder), \
|
||||||
|
patch('app.claim_sdr_device', return_value=None), \
|
||||||
|
patch('app.release_sdr_device') as mock_release:
|
||||||
|
scheduler._execute_capture_inner(sb)
|
||||||
|
progress_cb = mock_decoder.set_callback.call_args[0][0]
|
||||||
|
progress_cb({
|
||||||
|
'type': 'wefax_progress',
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'rtl_fm failed',
|
||||||
|
})
|
||||||
|
|
||||||
|
mock_release.assert_called_once_with(0)
|
||||||
|
assert sb.status == 'skipped'
|
||||||
|
|
||||||
|
def test_stop_capture_non_capturing_only_releases(self):
|
||||||
|
"""_stop_capture should be idempotent when capture already ended."""
|
||||||
|
scheduler = WeFaxScheduler()
|
||||||
|
sb = ScheduledBroadcast(
|
||||||
|
station='USCG Kodiak',
|
||||||
|
callsign='NOJ',
|
||||||
|
frequency_khz=4298.0,
|
||||||
|
utc_time='12:00',
|
||||||
|
duration_min=20,
|
||||||
|
content='Chart',
|
||||||
|
occurrence_date='2026-01-01',
|
||||||
|
)
|
||||||
|
sb.status = 'complete'
|
||||||
|
release_fn = MagicMock()
|
||||||
|
|
||||||
|
with patch('utils.wefax_scheduler.get_wefax_decoder') as mock_get_decoder:
|
||||||
|
scheduler._stop_capture(sb, release_fn)
|
||||||
|
|
||||||
|
release_fn.assert_called_once()
|
||||||
|
mock_get_decoder.assert_not_called()
|
||||||
+73
-56
@@ -173,7 +173,7 @@ class WeatherSatDecoder:
|
|||||||
self._current_frequency: float = 0.0
|
self._current_frequency: float = 0.0
|
||||||
self._current_mode: str = ''
|
self._current_mode: str = ''
|
||||||
self._capture_start_time: float = 0
|
self._capture_start_time: float = 0
|
||||||
self._device_index: int = 0
|
self._device_index: int = -1
|
||||||
self._capture_output_dir: Path | None = None
|
self._capture_output_dir: Path | None = None
|
||||||
self._on_complete_callback: Callable[[], None] | None = None
|
self._on_complete_callback: Callable[[], None] | None = None
|
||||||
self._capture_phase: str = 'idle'
|
self._capture_phase: str = 'idle'
|
||||||
@@ -303,12 +303,13 @@ class WeatherSatDecoder:
|
|||||||
))
|
))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self._current_satellite = satellite
|
self._current_satellite = satellite
|
||||||
self._current_frequency = sat_info['frequency']
|
self._current_frequency = sat_info['frequency']
|
||||||
self._current_mode = sat_info['mode']
|
self._current_mode = sat_info['mode']
|
||||||
self._capture_start_time = time.time()
|
self._device_index = -1 # Offline decode does not claim an SDR device
|
||||||
self._capture_phase = 'decoding'
|
self._capture_start_time = time.time()
|
||||||
self._stop_event.clear()
|
self._capture_phase = 'decoding'
|
||||||
|
self._stop_event.clear()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._running = True
|
self._running = True
|
||||||
@@ -464,15 +465,18 @@ class WeatherSatDecoder:
|
|||||||
master_fd, slave_fd = pty.openpty()
|
master_fd, slave_fd = pty.openpty()
|
||||||
self._pty_master_fd = master_fd
|
self._pty_master_fd = master_fd
|
||||||
|
|
||||||
self._process = subprocess.Popen(
|
self._process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=slave_fd,
|
stdout=slave_fd,
|
||||||
stderr=slave_fd,
|
stderr=slave_fd,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
close_fds=True,
|
close_fds=True,
|
||||||
)
|
)
|
||||||
register_process(self._process)
|
register_process(self._process)
|
||||||
os.close(slave_fd) # parent doesn't need the slave side
|
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)
|
# Check for early exit asynchronously (avoid blocking /start for 3s)
|
||||||
def _check_early_exit():
|
def _check_early_exit():
|
||||||
@@ -564,15 +568,18 @@ class WeatherSatDecoder:
|
|||||||
master_fd, slave_fd = pty.openpty()
|
master_fd, slave_fd = pty.openpty()
|
||||||
self._pty_master_fd = master_fd
|
self._pty_master_fd = master_fd
|
||||||
|
|
||||||
self._process = subprocess.Popen(
|
self._process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=slave_fd,
|
stdout=slave_fd,
|
||||||
stderr=slave_fd,
|
stderr=slave_fd,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
close_fds=True,
|
close_fds=True,
|
||||||
)
|
)
|
||||||
register_process(self._process)
|
register_process(self._process)
|
||||||
os.close(slave_fd) # parent doesn't need the slave side
|
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
|
# For offline mode, don't check for early exit — file decoding
|
||||||
# may complete very quickly and exit code 0 is normal success.
|
# may complete very quickly and exit code 0 is normal success.
|
||||||
@@ -892,16 +899,24 @@ class WeatherSatDecoder:
|
|||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Determine product type from filename/path
|
# Determine product type from filename/path
|
||||||
product = self._parse_product_name(filepath)
|
product = self._parse_product_name(filepath)
|
||||||
|
|
||||||
# Copy image to main output dir for serving
|
# Copy image to main output dir for serving
|
||||||
serve_name = f"{self._current_satellite}_{filepath.stem}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
safe_sat = re.sub(r'[^A-Za-z0-9_-]+', '_', self._current_satellite).strip('_') or 'satellite'
|
||||||
serve_path = self._output_dir / serve_name
|
safe_stem = re.sub(r'[^A-Za-z0-9_-]+', '_', filepath.stem).strip('_') or 'image'
|
||||||
try:
|
suffix = filepath.suffix.lower()
|
||||||
shutil.copy2(filepath, serve_path)
|
if suffix not in ('.png', '.jpg', '.jpeg'):
|
||||||
except OSError:
|
suffix = '.png'
|
||||||
# Copy failed — don't mark as known so it can be retried
|
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
|
continue
|
||||||
|
|
||||||
# Only mark as known after successful copy
|
# Only mark as known after successful copy
|
||||||
@@ -945,12 +960,12 @@ class WeatherSatDecoder:
|
|||||||
return 'Multispectral Analysis'
|
return 'Multispectral Analysis'
|
||||||
if 'thermal' in name or 'temp' in name:
|
if 'thermal' in name or 'temp' in name:
|
||||||
return 'Thermal'
|
return 'Thermal'
|
||||||
if 'ndvi' in name:
|
if 'ndvi' in name:
|
||||||
return 'NDVI Vegetation'
|
return 'NDVI Vegetation'
|
||||||
if 'channel' in name or 'ch' in name:
|
if 'channel' in name or 'ch' in name:
|
||||||
match = re.search(r'(?:channel|ch)\s*(\d+)', name)
|
match = re.search(r'(?:channel|ch)[\s_-]*(\d+)', name)
|
||||||
if match:
|
if match:
|
||||||
return f'Channel {match.group(1)}'
|
return f'Channel {match.group(1)}'
|
||||||
if 'avhrr' in name:
|
if 'avhrr' in name:
|
||||||
return 'AVHRR'
|
return 'AVHRR'
|
||||||
if 'msu' in name or 'mtvza' in name:
|
if 'msu' in name or 'mtvza' in name:
|
||||||
@@ -972,12 +987,13 @@ class WeatherSatDecoder:
|
|||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
self._close_pty()
|
self._close_pty()
|
||||||
|
|
||||||
if self._process:
|
if self._process:
|
||||||
safe_terminate(self._process)
|
safe_terminate(self._process)
|
||||||
self._process = None
|
self._process = None
|
||||||
|
|
||||||
elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0
|
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")
|
logger.info(f"Weather satellite capture stopped after {elapsed}s")
|
||||||
|
self._device_index = -1
|
||||||
|
|
||||||
def get_images(self) -> list[WeatherSatImage]:
|
def get_images(self) -> list[WeatherSatImage]:
|
||||||
"""Get list of decoded images."""
|
"""Get list of decoded images."""
|
||||||
@@ -1013,17 +1029,18 @@ class WeatherSatDecoder:
|
|||||||
|
|
||||||
sat_info = WEATHER_SATELLITES.get(satellite, {})
|
sat_info = WEATHER_SATELLITES.get(satellite, {})
|
||||||
|
|
||||||
image = WeatherSatImage(
|
image = WeatherSatImage(
|
||||||
filename=filepath.name,
|
filename=filepath.name,
|
||||||
path=filepath,
|
path=filepath,
|
||||||
satellite=satellite,
|
satellite=satellite,
|
||||||
mode=sat_info.get('mode', 'Unknown'),
|
mode=sat_info.get('mode', 'Unknown'),
|
||||||
timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||||
frequency=sat_info.get('frequency', 0.0),
|
frequency=sat_info.get('frequency', 0.0),
|
||||||
size_bytes=stat.st_size,
|
size_bytes=stat.st_size,
|
||||||
product=self._parse_product_name(filepath),
|
product=self._parse_product_name(filepath),
|
||||||
)
|
)
|
||||||
self._images.append(image)
|
self._images.append(image)
|
||||||
|
known_filenames.add(filepath.name)
|
||||||
|
|
||||||
def delete_image(self, filename: str) -> bool:
|
def delete_image(self, filename: str) -> bool:
|
||||||
"""Delete a decoded image."""
|
"""Delete a decoded image."""
|
||||||
|
|||||||
@@ -319,12 +319,26 @@ class WeatherSatScheduler:
|
|||||||
if self._progress_callback:
|
if self._progress_callback:
|
||||||
decoder.set_callback(self._progress_callback)
|
decoder.set_callback(self._progress_callback)
|
||||||
|
|
||||||
def _release_device():
|
def _release_device():
|
||||||
try:
|
try:
|
||||||
import app as app_module
|
import app as app_module
|
||||||
app_module.release_sdr_device(self._device)
|
owner = None
|
||||||
except ImportError:
|
get_status = getattr(app_module, 'get_sdr_device_status', None)
|
||||||
pass
|
if callable(get_status):
|
||||||
|
try:
|
||||||
|
owner = get_status().get(self._device)
|
||||||
|
except Exception:
|
||||||
|
owner = None
|
||||||
|
if owner and owner != 'weather_sat':
|
||||||
|
logger.debug(
|
||||||
|
"Skipping SDR release for device %s owned by %s",
|
||||||
|
self._device,
|
||||||
|
owner,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
app_module.release_sdr_device(self._device)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
decoder.set_on_complete(lambda: self._on_capture_complete(sp, _release_device))
|
decoder.set_on_complete(lambda: self._on_capture_complete(sp, _release_device))
|
||||||
|
|
||||||
|
|||||||
+160
-89
@@ -31,28 +31,30 @@ except ImportError:
|
|||||||
WEFAX_CAPTURE_BUFFER_SECONDS = 30
|
WEFAX_CAPTURE_BUFFER_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
class ScheduledBroadcast:
|
class ScheduledBroadcast:
|
||||||
"""A broadcast scheduled for automatic capture."""
|
"""A broadcast scheduled for automatic capture."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
station: str,
|
station: str,
|
||||||
callsign: str,
|
callsign: str,
|
||||||
frequency_khz: float,
|
frequency_khz: float,
|
||||||
utc_time: str,
|
utc_time: str,
|
||||||
duration_min: int,
|
duration_min: int,
|
||||||
content: str,
|
content: str,
|
||||||
):
|
occurrence_date: str = '',
|
||||||
self.id: str = str(uuid.uuid4())[:8]
|
):
|
||||||
self.station = station
|
self.id: str = str(uuid.uuid4())[:8]
|
||||||
self.callsign = callsign
|
self.station = station
|
||||||
self.frequency_khz = frequency_khz
|
self.callsign = callsign
|
||||||
self.utc_time = utc_time
|
self.frequency_khz = frequency_khz
|
||||||
self.duration_min = duration_min
|
self.utc_time = utc_time
|
||||||
self.content = content
|
self.duration_min = duration_min
|
||||||
self.status: str = 'scheduled' # scheduled, capturing, complete, skipped
|
self.content = content
|
||||||
self._timer: threading.Timer | None = None
|
self.occurrence_date = occurrence_date
|
||||||
self._stop_timer: threading.Timer | None = None
|
self.status: str = 'scheduled' # scheduled, capturing, complete, skipped
|
||||||
|
self._timer: threading.Timer | None = None
|
||||||
|
self._stop_timer: threading.Timer | None = None
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -60,14 +62,15 @@ class ScheduledBroadcast:
|
|||||||
'station': self.station,
|
'station': self.station,
|
||||||
'callsign': self.callsign,
|
'callsign': self.callsign,
|
||||||
'frequency_khz': self.frequency_khz,
|
'frequency_khz': self.frequency_khz,
|
||||||
'utc_time': self.utc_time,
|
'utc_time': self.utc_time,
|
||||||
'duration_min': self.duration_min,
|
'duration_min': self.duration_min,
|
||||||
'content': self.content,
|
'content': self.content,
|
||||||
'status': self.status,
|
'occurrence_date': self.occurrence_date,
|
||||||
}
|
'status': self.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class WeFaxScheduler:
|
class WeFaxScheduler:
|
||||||
"""Auto-scheduler for WeFax broadcast captures."""
|
"""Auto-scheduler for WeFax broadcast captures."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -204,10 +207,15 @@ class WeFaxScheduler:
|
|||||||
'total_broadcasts': len(self._broadcasts),
|
'total_broadcasts': len(self._broadcasts),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_broadcasts(self) -> list[dict[str, Any]]:
|
def get_broadcasts(self) -> list[dict[str, Any]]:
|
||||||
"""Get list of scheduled broadcasts."""
|
"""Get list of scheduled broadcasts."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return [b.to_dict() for b in self._broadcasts]
|
return [b.to_dict() for b in self._broadcasts]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _history_key(callsign: str, utc_time: str, occurrence_date: str) -> str:
|
||||||
|
"""Build a stable key for one station UTC slot on one calendar day."""
|
||||||
|
return f'{callsign}_{utc_time}_{occurrence_date}'
|
||||||
|
|
||||||
def _refresh_schedule(self) -> None:
|
def _refresh_schedule(self) -> None:
|
||||||
"""Recompute broadcast schedule and set timers."""
|
"""Recompute broadcast schedule and set timers."""
|
||||||
@@ -269,24 +277,34 @@ class WeFaxScheduler:
|
|||||||
minutes=duration_min, seconds=buffer
|
minutes=duration_min, seconds=buffer
|
||||||
)
|
)
|
||||||
|
|
||||||
capture_start = broadcast_dt - timedelta(seconds=buffer)
|
capture_start = broadcast_dt - timedelta(seconds=buffer)
|
||||||
|
occurrence_date = broadcast_dt.date().isoformat()
|
||||||
# Check if already in history
|
|
||||||
history_key = f"{self._callsign}_{utc_time}"
|
# Check if this specific day/slot was already processed.
|
||||||
if any(
|
history_key = self._history_key(
|
||||||
f"{h.callsign}_{h.utc_time}" == history_key
|
self._callsign,
|
||||||
for h in history
|
utc_time,
|
||||||
):
|
occurrence_date,
|
||||||
continue
|
)
|
||||||
|
if any(
|
||||||
sb = ScheduledBroadcast(
|
self._history_key(
|
||||||
|
h.callsign,
|
||||||
|
h.utc_time,
|
||||||
|
getattr(h, 'occurrence_date', ''),
|
||||||
|
) == history_key
|
||||||
|
for h in history
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
sb = ScheduledBroadcast(
|
||||||
station=self._station,
|
station=self._station,
|
||||||
callsign=self._callsign,
|
callsign=self._callsign,
|
||||||
frequency_khz=self._frequency_khz,
|
frequency_khz=self._frequency_khz,
|
||||||
utc_time=utc_time,
|
utc_time=utc_time,
|
||||||
duration_min=duration_min,
|
duration_min=duration_min,
|
||||||
content=content,
|
content=content,
|
||||||
)
|
occurrence_date=occurrence_date,
|
||||||
|
)
|
||||||
|
|
||||||
# Schedule capture timer
|
# Schedule capture timer
|
||||||
delay = max(0.0, (capture_start - now).total_seconds())
|
delay = max(0.0, (capture_start - now).total_seconds())
|
||||||
@@ -371,18 +389,57 @@ class WeFaxScheduler:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
sb.status = 'capturing'
|
sb.status = 'capturing'
|
||||||
|
|
||||||
# Set up callbacks
|
def _release_device():
|
||||||
if self._progress_callback:
|
try:
|
||||||
decoder.set_callback(self._progress_callback)
|
import app as app_module
|
||||||
|
app_module.release_sdr_device(self._device)
|
||||||
def _release_device():
|
except ImportError:
|
||||||
try:
|
pass
|
||||||
import app as app_module
|
|
||||||
app_module.release_sdr_device(self._device)
|
released = False
|
||||||
except ImportError:
|
release_lock = threading.Lock()
|
||||||
pass
|
|
||||||
|
def _release_device_once() -> None:
|
||||||
|
nonlocal released
|
||||||
|
with release_lock:
|
||||||
|
if released:
|
||||||
|
return
|
||||||
|
released = True
|
||||||
|
_release_device()
|
||||||
|
|
||||||
|
def _scheduler_progress_callback(progress: dict) -> None:
|
||||||
|
"""Forward progress updates and release scheduler resources on terminal states."""
|
||||||
|
if self._progress_callback:
|
||||||
|
self._progress_callback(progress)
|
||||||
|
|
||||||
|
if not isinstance(progress, dict) or progress.get('type') != 'wefax_progress':
|
||||||
|
return
|
||||||
|
|
||||||
|
status = progress.get('status')
|
||||||
|
if status not in ('complete', 'error', 'stopped'):
|
||||||
|
return
|
||||||
|
|
||||||
|
if sb.status == 'capturing':
|
||||||
|
if status == 'complete':
|
||||||
|
sb.status = 'complete'
|
||||||
|
self._emit_event({
|
||||||
|
'type': 'schedule_capture_complete',
|
||||||
|
'broadcast': sb.to_dict(),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
sb.status = 'skipped'
|
||||||
|
self._emit_event({
|
||||||
|
'type': 'schedule_capture_skipped',
|
||||||
|
'broadcast': sb.to_dict(),
|
||||||
|
'reason': 'decoder_error',
|
||||||
|
'detail': progress.get('message', ''),
|
||||||
|
})
|
||||||
|
|
||||||
|
_release_device_once()
|
||||||
|
|
||||||
|
decoder.set_callback(_scheduler_progress_callback)
|
||||||
|
|
||||||
success = decoder.start(
|
success = decoder.start(
|
||||||
frequency_khz=self._frequency_khz,
|
frequency_khz=self._frequency_khz,
|
||||||
@@ -414,39 +471,53 @@ class WeFaxScheduler:
|
|||||||
minutes=sb.duration_min,
|
minutes=sb.duration_min,
|
||||||
seconds=WEFAX_CAPTURE_BUFFER_SECONDS,
|
seconds=WEFAX_CAPTURE_BUFFER_SECONDS,
|
||||||
)
|
)
|
||||||
stop_delay = max(0.0, (stop_dt - now).total_seconds())
|
stop_delay = max(0.0, (stop_dt - now).total_seconds())
|
||||||
|
|
||||||
if stop_delay > 0:
|
if stop_delay > 0:
|
||||||
sb._stop_timer = threading.Timer(
|
sb._stop_timer = threading.Timer(
|
||||||
stop_delay, self._stop_capture, args=[sb, _release_device]
|
stop_delay, self._stop_capture, args=[sb, _release_device_once]
|
||||||
)
|
)
|
||||||
sb._stop_timer.daemon = True
|
sb._stop_timer.daemon = True
|
||||||
sb._stop_timer.start()
|
sb._stop_timer.start()
|
||||||
else:
|
else:
|
||||||
sb.status = 'skipped'
|
# If execution was delayed beyond end-of-window, close out
|
||||||
_release_device()
|
# immediately so SDR allocation is never stranded.
|
||||||
self._emit_event({
|
logger.warning(
|
||||||
'type': 'schedule_capture_skipped',
|
"Capture window already elapsed for %s at %s UTC; stopping immediately",
|
||||||
'broadcast': sb.to_dict(),
|
sb.content,
|
||||||
'reason': 'start_failed',
|
sb.utc_time,
|
||||||
'detail': decoder.last_error or 'unknown error',
|
)
|
||||||
|
self._stop_capture(sb, _release_device_once)
|
||||||
|
else:
|
||||||
|
sb.status = 'skipped'
|
||||||
|
_release_device_once()
|
||||||
|
self._emit_event({
|
||||||
|
'type': 'schedule_capture_skipped',
|
||||||
|
'broadcast': sb.to_dict(),
|
||||||
|
'reason': 'start_failed',
|
||||||
|
'detail': decoder.last_error or 'unknown error',
|
||||||
})
|
})
|
||||||
|
|
||||||
def _stop_capture(
|
def _stop_capture(
|
||||||
self, sb: ScheduledBroadcast, release_fn: Callable
|
self, sb: ScheduledBroadcast, release_fn: Callable
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Stop capture at broadcast end."""
|
"""Stop capture at broadcast end."""
|
||||||
decoder = get_wefax_decoder()
|
if sb.status != 'capturing':
|
||||||
if decoder.is_running:
|
release_fn()
|
||||||
decoder.stop()
|
return
|
||||||
logger.info("Auto-scheduler stopped capture: %s", sb.content)
|
|
||||||
|
sb.status = 'complete'
|
||||||
sb.status = 'complete'
|
|
||||||
release_fn()
|
decoder = get_wefax_decoder()
|
||||||
self._emit_event({
|
if decoder.is_running:
|
||||||
'type': 'schedule_capture_complete',
|
decoder.stop()
|
||||||
'broadcast': sb.to_dict(),
|
logger.info("Auto-scheduler stopped capture: %s", sb.content)
|
||||||
})
|
|
||||||
|
release_fn()
|
||||||
|
self._emit_event({
|
||||||
|
'type': 'schedule_capture_complete',
|
||||||
|
'broadcast': sb.to_dict(),
|
||||||
|
})
|
||||||
|
|
||||||
def _emit_event(self, event: dict[str, Any]) -> None:
|
def _emit_event(self, event: dict[str, Any]) -> None:
|
||||||
"""Emit scheduler event to callback."""
|
"""Emit scheduler event to callback."""
|
||||||
|
|||||||
Reference in New Issue
Block a user