mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 14:11:54 -07:00
Merge upstream/main and resolve weather-satellite.js conflict
Resolved conflict in static/js/modes/weather-satellite.js: - Kept allPasses state variable and applyPassFilter() for satellite pass filtering - Kept satellite select dropdown listener for filter feature - Adopted upstream's optimistic stop() UI pattern for better responsiveness - Kept optional chaining (pass?.trajectory) since drawPolarPlot can receive null Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.
|
||||
@@ -198,6 +198,11 @@ tscm_lock = threading.Lock()
|
||||
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
subghz_lock = threading.Lock()
|
||||
|
||||
# CW/Morse code decoder
|
||||
morse_process = None
|
||||
morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
morse_lock = threading.Lock()
|
||||
|
||||
# Deauth Attack Detection
|
||||
deauth_detector = None
|
||||
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
@@ -755,6 +760,7 @@ def health_check() -> Response:
|
||||
'wifi': wifi_active,
|
||||
'bluetooth': bt_active,
|
||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||
'morse': morse_process is not None and (morse_process.poll() is None if morse_process else False),
|
||||
'subghz': _get_subghz_active(),
|
||||
},
|
||||
'data': {
|
||||
@@ -772,7 +778,7 @@ def health_check() -> Response:
|
||||
def kill_all() -> Response:
|
||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||
global vdl2_process
|
||||
global vdl2_process, morse_process
|
||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
||||
|
||||
# Import adsb and ais modules to reset their state
|
||||
@@ -825,6 +831,10 @@ def kill_all() -> Response:
|
||||
with vdl2_lock:
|
||||
vdl2_process = None
|
||||
|
||||
# Reset Morse state
|
||||
with morse_lock:
|
||||
morse_process = None
|
||||
|
||||
# Reset APRS state
|
||||
with aprs_lock:
|
||||
aprs_process = None
|
||||
|
||||
@@ -331,12 +331,20 @@ SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
||||
|
||||
# Weather satellite settings
|
||||
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
|
||||
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 1000000)
|
||||
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 2400000)
|
||||
WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0)
|
||||
WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
|
||||
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30)
|
||||
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30)
|
||||
|
||||
# WeFax (Weather Fax) settings
|
||||
WEFAX_DEFAULT_GAIN = _get_env_float('WEFAX_GAIN', 40.0)
|
||||
WEFAX_SAMPLE_RATE = _get_env_int('WEFAX_SAMPLE_RATE', 22050)
|
||||
WEFAX_DEFAULT_IOC = _get_env_int('WEFAX_IOC', 576)
|
||||
WEFAX_DEFAULT_LPM = _get_env_int('WEFAX_LPM', 120)
|
||||
WEFAX_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEFAX_SCHEDULE_REFRESH_MINUTES', 30)
|
||||
WEFAX_CAPTURE_BUFFER_SECONDS = _get_env_int('WEFAX_CAPTURE_BUFFER_SECONDS', 30)
|
||||
|
||||
# SubGHz transceiver settings (HackRF)
|
||||
SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92)
|
||||
SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int('SUBGHZ_SAMPLE_RATE', 2000000)
|
||||
|
||||
@@ -0,0 +1,733 @@
|
||||
{
|
||||
"stations": [
|
||||
{
|
||||
"name": "USCG Kodiak",
|
||||
"callsign": "NOJ",
|
||||
"country": "US",
|
||||
"city": "Kodiak, AK",
|
||||
"coordinates": [57.78, -152.50],
|
||||
"frequencies": [
|
||||
{"khz": 2054, "description": "Night"},
|
||||
{"khz": 4298, "description": "Primary"},
|
||||
{"khz": 8459, "description": "Day"},
|
||||
{"khz": 12412.5, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "03:40", "duration_min": 148, "content": "Chart Series 1"},
|
||||
{"utc": "09:50", "duration_min": 138, "content": "Chart Series 2"},
|
||||
{"utc": "15:40", "duration_min": 148, "content": "Chart Series 3"},
|
||||
{"utc": "21:50", "duration_min": 98, "content": "Chart Series 4"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "USCG Boston",
|
||||
"callsign": "NMF",
|
||||
"country": "US",
|
||||
"city": "Boston, MA",
|
||||
"coordinates": [42.36, -71.04],
|
||||
"frequencies": [
|
||||
{"khz": 4235, "description": "Night"},
|
||||
{"khz": 6340.5, "description": "Primary"},
|
||||
{"khz": 9110, "description": "Day"},
|
||||
{"khz": 12750, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "02:30", "duration_min": 20, "content": "Wind/Wave Analysis"},
|
||||
{"utc": "04:38", "duration_min": 20, "content": "Sea State Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "09:30", "duration_min": 20, "content": "48-Hour Surface Prog"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "14:00", "duration_min": 20, "content": "24-Hour Surface Prog"},
|
||||
{"utc": "16:00", "duration_min": 20, "content": "Sea State Analysis"},
|
||||
{"utc": "18:10", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "22:00", "duration_min": 20, "content": "Satellite Image"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "USCG New Orleans",
|
||||
"callsign": "NMG",
|
||||
"country": "US",
|
||||
"city": "New Orleans, LA",
|
||||
"coordinates": [29.95, -90.07],
|
||||
"frequencies": [
|
||||
{"khz": 4317.9, "description": "Night"},
|
||||
{"khz": 8503.9, "description": "Primary"},
|
||||
{"khz": 12789.9, "description": "Day"},
|
||||
{"khz": 17146.4, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "03:00", "duration_min": 20, "content": "24-Hour Surface Prog"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "09:00", "duration_min": 20, "content": "Sea State Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "15:00", "duration_min": 20, "content": "48-Hour Surface Prog"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "21:00", "duration_min": 20, "content": "Tropical Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "USCG Pt. Reyes",
|
||||
"callsign": "NMC",
|
||||
"country": "US",
|
||||
"city": "Pt. Reyes, CA",
|
||||
"coordinates": [38.07, -122.97],
|
||||
"frequencies": [
|
||||
{"khz": 4346, "description": "Night"},
|
||||
{"khz": 8682, "description": "Primary"},
|
||||
{"khz": 12786, "description": "Day"},
|
||||
{"khz": 17151.2, "description": "Extended"},
|
||||
{"khz": 22527, "description": "DX"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "01:40", "duration_min": 20, "content": "Wind/Wave Analysis"},
|
||||
{"utc": "06:55", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "11:20", "duration_min": 20, "content": "48-Hour Surface Prog"},
|
||||
{"utc": "14:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:40", "duration_min": 20, "content": "Sea State Analysis"},
|
||||
{"utc": "23:20", "duration_min": 20, "content": "Satellite Image"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "USCG Honolulu",
|
||||
"callsign": "KVM70",
|
||||
"country": "US",
|
||||
"city": "Honolulu, HI",
|
||||
"coordinates": [21.31, -157.86],
|
||||
"frequencies": [
|
||||
{"khz": 9982.5, "description": "Primary"},
|
||||
{"khz": 11090, "description": "Day"},
|
||||
{"khz": 16135, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "05:19", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "17:19", "duration_min": 20, "content": "Sea State Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "RN Northwood",
|
||||
"callsign": "GYA",
|
||||
"country": "GB",
|
||||
"city": "Northwood, London",
|
||||
"coordinates": [51.63, -0.42],
|
||||
"frequencies": [
|
||||
{"khz": 2618.5, "description": "Night"},
|
||||
{"khz": 3280.5, "description": "Night Alt"},
|
||||
{"khz": 4610, "description": "Primary"},
|
||||
{"khz": 6834, "description": "Day Alt"},
|
||||
{"khz": 8040, "description": "Day"},
|
||||
{"khz": 11086.5, "description": "Extended"},
|
||||
{"khz": 12390, "description": "Persian Gulf"},
|
||||
{"khz": 18261, "description": "DX"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "03:30", "duration_min": 20, "content": "24-Hour Surface Prog"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "08:00", "duration_min": 20, "content": "Sea State Forecast"},
|
||||
{"utc": "09:30", "duration_min": 20, "content": "Extended Forecast"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "15:30", "duration_min": 20, "content": "48-Hour Surface Prog"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "19:00", "duration_min": 20, "content": "Wave Period Forecast"},
|
||||
{"utc": "21:30", "duration_min": 20, "content": "Extended Forecast"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "DWD Hamburg/Pinneberg",
|
||||
"callsign": "DDH",
|
||||
"country": "DE",
|
||||
"city": "Pinneberg",
|
||||
"coordinates": [53.66, 9.80],
|
||||
"frequencies": [
|
||||
{"khz": 3855, "description": "Night (DDH3, 10kW)"},
|
||||
{"khz": 7880, "description": "Primary (DDK3, 20kW)"},
|
||||
{"khz": 13882.5, "description": "Day (DDK6, 20kW)"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "04:30", "duration_min": 20, "content": "Surface Analysis N. Atlantic"},
|
||||
{"utc": "07:15", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "09:30", "duration_min": 20, "content": "Surface Analysis Europe"},
|
||||
{"utc": "10:07", "duration_min": 20, "content": "Sea State North Sea"},
|
||||
{"utc": "13:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "15:20", "duration_min": 20, "content": "Extended Prog"},
|
||||
{"utc": "15:40", "duration_min": 20, "content": "Sea Ice Chart"},
|
||||
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "21:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "21:15", "duration_min": 20, "content": "Surface Prog"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "JMA Tokyo",
|
||||
"callsign": "JMH",
|
||||
"country": "JP",
|
||||
"city": "Tokyo",
|
||||
"coordinates": [35.69, 139.69],
|
||||
"frequencies": [
|
||||
{"khz": 3622.5, "description": "Night"},
|
||||
{"khz": 7795, "description": "Primary"},
|
||||
{"khz": 13988.5, "description": "Day"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "01:30", "duration_min": 20, "content": "24-Hour Prog"},
|
||||
{"utc": "03:00", "duration_min": 20, "content": "Satellite Image"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "07:30", "duration_min": 20, "content": "Wave Analysis"},
|
||||
{"utc": "09:00", "duration_min": 20, "content": "Satellite Image"},
|
||||
{"utc": "10:19", "duration_min": 20, "content": "Tropical Cyclone Info"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "15:00", "duration_min": 20, "content": "Satellite Image"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "21:00", "duration_min": 20, "content": "48-Hour Prog"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Kyodo News Tokyo",
|
||||
"callsign": "JJC",
|
||||
"country": "JP",
|
||||
"city": "Tokyo",
|
||||
"coordinates": [35.69, 139.69],
|
||||
"frequencies": [
|
||||
{"khz": 4316, "description": "Night"},
|
||||
{"khz": 8467.5, "description": "Primary"},
|
||||
{"khz": 12745.5, "description": "Day"},
|
||||
{"khz": 16971, "description": "Extended"},
|
||||
{"khz": 17069.6, "description": "DX"},
|
||||
{"khz": 22542, "description": "DX 2"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
||||
{"utc": "04:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
||||
{"utc": "08:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
||||
{"utc": "16:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
||||
{"utc": "20:00", "duration_min": 20, "content": "Press Photo/News Fax"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Kagoshima Fisheries",
|
||||
"callsign": "JFX",
|
||||
"country": "JP",
|
||||
"city": "Kagoshima",
|
||||
"coordinates": [31.60, 130.56],
|
||||
"frequencies": [
|
||||
{"khz": 4274, "description": "Night"},
|
||||
{"khz": 8658, "description": "Primary"},
|
||||
{"khz": 13074, "description": "Day"},
|
||||
{"khz": 16907.5, "description": "Extended"},
|
||||
{"khz": 22559.6, "description": "DX"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Sea Surface Temp"},
|
||||
{"utc": "04:00", "duration_min": 20, "content": "Fishing Forecast"},
|
||||
{"utc": "08:00", "duration_min": 20, "content": "Sea Surface Temp"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Current Chart"},
|
||||
{"utc": "16:00", "duration_min": 20, "content": "Fishing Forecast"},
|
||||
{"utc": "20:00", "duration_min": 20, "content": "Sea Surface Temp"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "KMA Seoul",
|
||||
"callsign": "HLL2",
|
||||
"country": "KR",
|
||||
"city": "Seoul",
|
||||
"coordinates": [37.57, 126.98],
|
||||
"frequencies": [
|
||||
{"khz": 3585, "description": "Night"},
|
||||
{"khz": 5857.5, "description": "Primary"},
|
||||
{"khz": 7433.5, "description": "Day"},
|
||||
{"khz": 9165, "description": "Extended"},
|
||||
{"khz": 13570, "description": "DX"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "03:00", "duration_min": 20, "content": "24-Hour Prog"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "09:00", "duration_min": 20, "content": "Satellite Image"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "15:00", "duration_min": 20, "content": "Sea State Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "21:00", "duration_min": 20, "content": "48-Hour Prog"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Taipei Met",
|
||||
"callsign": "BMF",
|
||||
"country": "TW",
|
||||
"city": "Taipei",
|
||||
"coordinates": [25.03, 121.57],
|
||||
"frequencies": [
|
||||
{"khz": 4616, "description": "Primary"},
|
||||
{"khz": 8140, "description": "Day"},
|
||||
{"khz": 13900, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Bangkok Met",
|
||||
"callsign": "HSW64",
|
||||
"country": "TH",
|
||||
"city": "Bangkok",
|
||||
"coordinates": [13.76, 100.50],
|
||||
"frequencies": [
|
||||
{"khz": 7396.8, "description": "Primary"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Shanghai Met",
|
||||
"callsign": "XSG",
|
||||
"country": "CN",
|
||||
"city": "Shanghai",
|
||||
"coordinates": [31.23, 121.47],
|
||||
"frequencies": [
|
||||
{"khz": 4170, "description": "Night"},
|
||||
{"khz": 8302, "description": "Primary"},
|
||||
{"khz": 12382, "description": "Day"},
|
||||
{"khz": 16559, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Guangzhou Radio",
|
||||
"callsign": "XSQ",
|
||||
"country": "CN",
|
||||
"city": "Guangzhou",
|
||||
"coordinates": [23.13, 113.26],
|
||||
"frequencies": [
|
||||
{"khz": 4199.8, "description": "Night"},
|
||||
{"khz": 8412.5, "description": "Primary"},
|
||||
{"khz": 12629.3, "description": "Day"},
|
||||
{"khz": 16826.3, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Singapore Met",
|
||||
"callsign": "9VF",
|
||||
"country": "SG",
|
||||
"city": "Singapore",
|
||||
"coordinates": [1.35, 103.82],
|
||||
"frequencies": [
|
||||
{"khz": 16035, "description": "Primary"},
|
||||
{"khz": 17430, "description": "Alternate"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "New Delhi Met",
|
||||
"callsign": "ATP",
|
||||
"country": "IN",
|
||||
"city": "New Delhi",
|
||||
"coordinates": [28.61, 77.21],
|
||||
"frequencies": [
|
||||
{"khz": 7405, "description": "Night"},
|
||||
{"khz": 14842, "description": "Day"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Murmansk Met",
|
||||
"callsign": "RBW",
|
||||
"country": "RU",
|
||||
"city": "Murmansk",
|
||||
"coordinates": [68.97, 33.09],
|
||||
"frequencies": [
|
||||
{"khz": 6445.5, "description": "Night"},
|
||||
{"khz": 7907, "description": "Primary"},
|
||||
{"khz": 8444, "description": "Day"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "07:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "08:00", "duration_min": 20, "content": "Ice Chart"},
|
||||
{"utc": "14:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "14:30", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "20:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "St. Petersburg Met",
|
||||
"callsign": "RDD78",
|
||||
"country": "RU",
|
||||
"city": "St. Petersburg",
|
||||
"coordinates": [59.93, 30.32],
|
||||
"frequencies": [
|
||||
{"khz": 2640, "description": "Night"},
|
||||
{"khz": 4212, "description": "Primary"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Athens Met",
|
||||
"callsign": "SVJ4",
|
||||
"country": "GR",
|
||||
"city": "Athens",
|
||||
"coordinates": [37.97, 23.73],
|
||||
"frequencies": [
|
||||
{"khz": 4482.9, "description": "Night"},
|
||||
{"khz": 8106.9, "description": "Primary"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis Med"},
|
||||
{"utc": "09:00", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis Med"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis Med"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Charleville Met",
|
||||
"callsign": "VMC",
|
||||
"country": "AU",
|
||||
"city": "Charleville, QLD",
|
||||
"coordinates": [-26.41, 146.24],
|
||||
"frequencies": [
|
||||
{"khz": 2628, "description": "Night"},
|
||||
{"khz": 5100, "description": "Primary"},
|
||||
{"khz": 11030, "description": "Day"},
|
||||
{"khz": 13920, "description": "Extended"},
|
||||
{"khz": 20469, "description": "DX"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||
{"utc": "03:00", "duration_min": 20, "content": "Prognosis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||
{"utc": "09:00", "duration_min": 20, "content": "Sea/Swell Chart"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||
{"utc": "19:00", "duration_min": 20, "content": "Prognosis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Wiluna Met",
|
||||
"callsign": "VMW",
|
||||
"country": "AU",
|
||||
"city": "Wiluna, WA",
|
||||
"coordinates": [-26.59, 120.23],
|
||||
"frequencies": [
|
||||
{"khz": 5755, "description": "Night"},
|
||||
{"khz": 7535, "description": "Primary"},
|
||||
{"khz": 10555, "description": "Day"},
|
||||
{"khz": 15615, "description": "Extended"},
|
||||
{"khz": 18060, "description": "DX"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||
{"utc": "11:00", "duration_min": 20, "content": "Prognosis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||
{"utc": "21:00", "duration_min": 20, "content": "Sea/Swell Chart"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "NZ MetService",
|
||||
"callsign": "ZKLF",
|
||||
"country": "NZ",
|
||||
"city": "Auckland",
|
||||
"coordinates": [-36.85, 174.76],
|
||||
"frequencies": [
|
||||
{"khz": 3247.4, "description": "Night"},
|
||||
{"khz": 5807, "description": "Primary"},
|
||||
{"khz": 9459, "description": "Day"},
|
||||
{"khz": 13550.5, "description": "Extended"},
|
||||
{"khz": 16340.1, "description": "DX"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "CFH Halifax",
|
||||
"callsign": "CFH",
|
||||
"country": "CA",
|
||||
"city": "Halifax, NS",
|
||||
"coordinates": [44.65, -63.57],
|
||||
"frequencies": [
|
||||
{"khz": 4271, "description": "Night"},
|
||||
{"khz": 6496.4, "description": "Primary"},
|
||||
{"khz": 10536, "description": "Day"},
|
||||
{"khz": 13510, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "03:00", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "22:22", "duration_min": 20, "content": "Ice Chart"},
|
||||
{"utc": "23:01", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "CCG Iqaluit",
|
||||
"callsign": "VFF",
|
||||
"country": "CA",
|
||||
"city": "Iqaluit, NU",
|
||||
"coordinates": [63.75, -68.52],
|
||||
"frequencies": [
|
||||
{"khz": 3253, "description": "Night"},
|
||||
{"khz": 7710, "description": "Day"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:10", "duration_min": 20, "content": "Ice Chart"},
|
||||
{"utc": "05:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "07:00", "duration_min": 20, "content": "Ice Chart"},
|
||||
{"utc": "10:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "11:00", "duration_min": 20, "content": "Ice Chart"},
|
||||
{"utc": "21:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "23:30", "duration_min": 20, "content": "Ice Chart"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "CCG Inuvik",
|
||||
"callsign": "VFA",
|
||||
"country": "CA",
|
||||
"city": "Inuvik, NT",
|
||||
"coordinates": [68.36, -133.72],
|
||||
"frequencies": [
|
||||
{"khz": 4292, "description": "Night"},
|
||||
{"khz": 8457.8, "description": "Primary"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "02:00", "duration_min": 20, "content": "Ice Chart"},
|
||||
{"utc": "16:30", "duration_min": 20, "content": "Ice Chart"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "CCG Sydney",
|
||||
"callsign": "VCO",
|
||||
"country": "CA",
|
||||
"city": "Sydney, NS",
|
||||
"coordinates": [46.14, -60.19],
|
||||
"frequencies": [
|
||||
{"khz": 4416, "description": "Night"},
|
||||
{"khz": 6915.1, "description": "Primary"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "11:21", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "11:42", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "17:41", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "22:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "23:31", "duration_min": 20, "content": "Surface Prog"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Cape Naval",
|
||||
"callsign": "ZSJ",
|
||||
"country": "ZA",
|
||||
"city": "Cape Town",
|
||||
"coordinates": [-33.92, 18.42],
|
||||
"frequencies": [
|
||||
{"khz": 4014, "description": "Night"},
|
||||
{"khz": 7508, "description": "Primary"},
|
||||
{"khz": 13538, "description": "Day"},
|
||||
{"khz": 18238, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "04:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "05:00", "duration_min": 20, "content": "Sea State"},
|
||||
{"utc": "06:30", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "07:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "08:00", "duration_min": 20, "content": "Satellite Image"},
|
||||
{"utc": "10:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "11:00", "duration_min": 20, "content": "Sea State"},
|
||||
{"utc": "15:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "15:40", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "22:30", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Valparaiso Naval",
|
||||
"callsign": "CBV",
|
||||
"country": "CL",
|
||||
"city": "Valparaiso",
|
||||
"coordinates": [-33.05, -71.62],
|
||||
"frequencies": [
|
||||
{"khz": 4228, "description": "Night"},
|
||||
{"khz": 8677, "description": "Primary"},
|
||||
{"khz": 17146.4, "description": "Day"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "11:15", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "11:30", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "16:45", "duration_min": 20, "content": "Sea State"},
|
||||
{"utc": "19:15", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "19:30", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "22:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "23:10", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "23:25", "duration_min": 20, "content": "Sea State"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Magallanes Naval",
|
||||
"callsign": "CBM",
|
||||
"country": "CL",
|
||||
"city": "Punta Arenas",
|
||||
"coordinates": [-53.16, -70.91],
|
||||
"frequencies": [
|
||||
{"khz": 4322, "description": "Night"},
|
||||
{"khz": 8696, "description": "Primary"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "01:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "13:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Rio de Janeiro Naval",
|
||||
"callsign": "PWZ33",
|
||||
"country": "BR",
|
||||
"city": "Rio de Janeiro",
|
||||
"coordinates": [-22.91, -43.17],
|
||||
"frequencies": [
|
||||
{"khz": 12665, "description": "Primary"},
|
||||
{"khz": 16978, "description": "Day"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "07:45", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Dakar Met",
|
||||
"callsign": "6VU",
|
||||
"country": "SN",
|
||||
"city": "Dakar",
|
||||
"coordinates": [14.69, -17.44],
|
||||
"frequencies": [
|
||||
{"khz": 13667.5, "description": "Primary"},
|
||||
{"khz": 19750, "description": "Day"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Misaki Fisheries",
|
||||
"callsign": "JFC",
|
||||
"country": "JP",
|
||||
"city": "Miura",
|
||||
"coordinates": [35.14, 139.62],
|
||||
"frequencies": [
|
||||
{"khz": 8616, "description": "Primary"},
|
||||
{"khz": 13074, "description": "Day"},
|
||||
{"khz": 17231, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Sea Surface Temp"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Current Chart"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Fishing Forecast"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Sea Surface Temp"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+16
-5
@@ -2860,11 +2860,22 @@ class ModeManager:
|
||||
pass
|
||||
logger.info("APRS reader stopped")
|
||||
|
||||
def _parse_aprs_packet(self, line: str) -> dict | None:
|
||||
"""Parse APRS packet from direwolf or multimon-ng."""
|
||||
match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line)
|
||||
if not match:
|
||||
return None
|
||||
def _parse_aprs_packet(self, line: str) -> dict | None:
|
||||
"""Parse APRS packet from direwolf or multimon-ng."""
|
||||
if not line:
|
||||
return None
|
||||
|
||||
# Normalize common decoder prefixes before parsing.
|
||||
# multimon-ng: "AFSK1200: ..."
|
||||
# direwolf: "[0.4] ...", "[0L] ..."
|
||||
line = line.strip()
|
||||
if line.startswith('AFSK1200:'):
|
||||
line = line[9:].strip()
|
||||
line = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', line)
|
||||
|
||||
match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
callsign = match.group(1)
|
||||
path = match.group(2)
|
||||
|
||||
+9
-5
@@ -14,8 +14,9 @@ def register_blueprints(app):
|
||||
from .correlation import correlation_bp
|
||||
from .dsc import dsc_bp
|
||||
from .gps import gps_bp
|
||||
from .listening_post import receiver_bp
|
||||
from .listening_post import receiver_bp
|
||||
from .meshtastic import meshtastic_bp
|
||||
from .morse import morse_bp
|
||||
from .offline import offline_bp
|
||||
from .pager import pager_bp
|
||||
from .recordings import recordings_bp
|
||||
@@ -33,6 +34,7 @@ def register_blueprints(app):
|
||||
from .updater import updater_bp
|
||||
from .vdl2 import vdl2_bp
|
||||
from .weather_sat import weather_sat_bp
|
||||
from .wefax import wefax_bp
|
||||
from .websdr import websdr_bp
|
||||
from .wifi import wifi_bp
|
||||
from .wifi_v2 import wifi_v2_bp
|
||||
@@ -54,7 +56,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(gps_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(correlation_bp)
|
||||
app.register_blueprint(receiver_bp)
|
||||
app.register_blueprint(receiver_bp)
|
||||
app.register_blueprint(meshtastic_bp)
|
||||
app.register_blueprint(tscm_bp)
|
||||
app.register_blueprint(spy_stations_bp)
|
||||
@@ -68,9 +70,11 @@ def register_blueprints(app):
|
||||
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
||||
app.register_blueprint(recordings_bp) # Session recordings
|
||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
||||
app.register_blueprint(signalid_bp) # External signal ID enrichment
|
||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
||||
app.register_blueprint(signalid_bp) # External signal ID enrichment
|
||||
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
|
||||
app.register_blueprint(morse_bp) # CW/Morse code decoder
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
|
||||
+2
-2
@@ -685,7 +685,7 @@ def start_adsb():
|
||||
'session': session
|
||||
}), 409
|
||||
|
||||
data = request.json or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
start_source = data.get('source')
|
||||
started_by = request.remote_addr
|
||||
|
||||
@@ -899,7 +899,7 @@ def start_adsb():
|
||||
def stop_adsb():
|
||||
"""Stop ADS-B tracking."""
|
||||
global adsb_using_service, adsb_active_device
|
||||
data = request.json or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
stop_source = data.get('source')
|
||||
stopped_by = request.remote_addr
|
||||
|
||||
|
||||
+189
-41
@@ -14,14 +14,14 @@ import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from subprocess import PIPE, STDOUT
|
||||
from typing import Generator, Optional
|
||||
from typing import Any, Generator, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
@@ -109,6 +109,26 @@ MODEM 1200
|
||||
return DIREWOLF_CONFIG_PATH
|
||||
|
||||
|
||||
def normalize_aprs_output_line(line: str) -> str:
|
||||
"""Normalize a decoder output line to raw APRS packet format.
|
||||
|
||||
Handles common decoder prefixes:
|
||||
- multimon-ng: ``AFSK1200: ...``
|
||||
- direwolf tags: ``[0.4] ...``, ``[0L] ...``, etc.
|
||||
"""
|
||||
if not line:
|
||||
return ''
|
||||
|
||||
normalized = line.strip()
|
||||
if normalized.startswith('AFSK1200:'):
|
||||
normalized = normalized[9:].strip()
|
||||
|
||||
# Strip one or more leading bracket tags emitted by decoders.
|
||||
# Examples: [0.4], [0L], [NONE]
|
||||
normalized = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
|
||||
"""Parse APRS packet into structured data.
|
||||
|
||||
@@ -125,10 +145,15 @@ def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
|
||||
- User-defined formats
|
||||
"""
|
||||
try:
|
||||
raw_packet = normalize_aprs_output_line(raw_packet)
|
||||
if not raw_packet:
|
||||
return None
|
||||
|
||||
# Basic APRS packet format: CALLSIGN>PATH:DATA
|
||||
# Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077
|
||||
|
||||
match = re.match(r'^([A-Z0-9-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE)
|
||||
# Source callsigns can include tactical suffixes like "/1" on some stations.
|
||||
match = re.match(r'^([A-Z0-9/\-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
@@ -444,6 +469,109 @@ def parse_position(data: str) -> Optional[dict]:
|
||||
|
||||
return result
|
||||
|
||||
# Legacy/no-decimal variant occasionally seen in degraded decodes:
|
||||
# DDMMN/DDDMMW (symbol chars still present between/after coords).
|
||||
nodot_match = re.match(
|
||||
r'^(\d{2})(\d{2})([NS])(.)(\d{3})(\d{2})([EW])(.)?',
|
||||
data
|
||||
)
|
||||
if nodot_match:
|
||||
lat_deg = int(nodot_match.group(1))
|
||||
lat_min = float(nodot_match.group(2))
|
||||
lat_dir = nodot_match.group(3)
|
||||
symbol_table = nodot_match.group(4)
|
||||
lon_deg = int(nodot_match.group(5))
|
||||
lon_min = float(nodot_match.group(6))
|
||||
lon_dir = nodot_match.group(7)
|
||||
symbol_code = nodot_match.group(8) or ''
|
||||
|
||||
lat = lat_deg + lat_min / 60.0
|
||||
if lat_dir == 'S':
|
||||
lat = -lat
|
||||
|
||||
lon = lon_deg + lon_min / 60.0
|
||||
if lon_dir == 'W':
|
||||
lon = -lon
|
||||
|
||||
result = {
|
||||
'lat': round(lat, 6),
|
||||
'lon': round(lon, 6),
|
||||
'symbol': symbol_table + symbol_code,
|
||||
}
|
||||
|
||||
remaining = data[13:] if len(data) > 13 else ''
|
||||
|
||||
cs_match = re.search(r'(\d{3})/(\d{3})', remaining)
|
||||
if cs_match:
|
||||
result['course'] = int(cs_match.group(1))
|
||||
result['speed'] = int(cs_match.group(2))
|
||||
|
||||
alt_match = re.search(r'/A=(-?\d+)', remaining)
|
||||
if alt_match:
|
||||
result['altitude'] = int(alt_match.group(1))
|
||||
|
||||
return result
|
||||
|
||||
# Fallback: tolerate APRS ambiguity spaces in minute fields.
|
||||
# Example: 4903. N/07201. W
|
||||
if len(data) >= 18:
|
||||
lat_field = data[0:7]
|
||||
lat_dir = data[7]
|
||||
symbol_table = data[8] if len(data) > 8 else ''
|
||||
lon_field = data[9:17] if len(data) >= 17 else ''
|
||||
lon_dir = data[17] if len(data) > 17 else ''
|
||||
symbol_code = data[18] if len(data) > 18 else ''
|
||||
|
||||
if (
|
||||
len(lat_field) == 7
|
||||
and len(lon_field) == 8
|
||||
and lat_dir in ('N', 'S')
|
||||
and lon_dir in ('E', 'W')
|
||||
):
|
||||
lat_deg_txt = lat_field[:2]
|
||||
lat_min_txt = lat_field[2:].replace(' ', '0')
|
||||
lon_deg_txt = lon_field[:3]
|
||||
lon_min_txt = lon_field[3:].replace(' ', '0')
|
||||
|
||||
if (
|
||||
lat_deg_txt.isdigit()
|
||||
and lon_deg_txt.isdigit()
|
||||
and re.match(r'^\d{2}\.\d+$', lat_min_txt)
|
||||
and re.match(r'^\d{2}\.\d+$', lon_min_txt)
|
||||
):
|
||||
lat_deg = int(lat_deg_txt)
|
||||
lon_deg = int(lon_deg_txt)
|
||||
lat_min = float(lat_min_txt)
|
||||
lon_min = float(lon_min_txt)
|
||||
|
||||
lat = lat_deg + lat_min / 60.0
|
||||
if lat_dir == 'S':
|
||||
lat = -lat
|
||||
|
||||
lon = lon_deg + lon_min / 60.0
|
||||
if lon_dir == 'W':
|
||||
lon = -lon
|
||||
|
||||
result = {
|
||||
'lat': round(lat, 6),
|
||||
'lon': round(lon, 6),
|
||||
'symbol': symbol_table + symbol_code,
|
||||
}
|
||||
|
||||
# Keep same extension parsing behavior as primary branch.
|
||||
remaining = data[19:] if len(data) > 19 else ''
|
||||
|
||||
cs_match = re.search(r'(\d{3})/(\d{3})', remaining)
|
||||
if cs_match:
|
||||
result['course'] = int(cs_match.group(1))
|
||||
result['speed'] = int(cs_match.group(2))
|
||||
|
||||
alt_match = re.search(r'/A=(-?\d+)', remaining)
|
||||
if alt_match:
|
||||
result['altitude'] = int(alt_match.group(1))
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse position: {e}")
|
||||
|
||||
@@ -1321,7 +1449,11 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
- type='meter': Audio level meter readings (rate-limited)
|
||||
"""
|
||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||
global _last_meter_time, _last_meter_level
|
||||
global _last_meter_time, _last_meter_level, aprs_active_device
|
||||
|
||||
# Capture the device claimed by THIS session so the finally block only
|
||||
# releases our own device, not one claimed by a subsequent start.
|
||||
my_device = aprs_active_device
|
||||
|
||||
# Reset meter state
|
||||
_last_meter_time = 0.0
|
||||
@@ -1348,13 +1480,8 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
app_module.aprs_queue.put(meter_msg)
|
||||
continue # Audio level lines are not packets
|
||||
|
||||
# multimon-ng prefixes decoded packets with "AFSK1200: "
|
||||
if line.startswith('AFSK1200:'):
|
||||
line = line[9:].strip()
|
||||
|
||||
# direwolf often prefixes packets with "[0.4] " or similar audio level indicator
|
||||
# Strip any leading bracket prefix like "[0.4] " before parsing
|
||||
line = re.sub(r'^\[\d+\.\d+\]\s*', '', line)
|
||||
# Normalize decoder prefixes (multimon/direwolf) before parsing.
|
||||
line = normalize_aprs_output_line(line)
|
||||
|
||||
# Skip non-packet lines (APRS format: CALL>PATH:DATA)
|
||||
if '>' not in line or ':' not in line:
|
||||
@@ -1370,20 +1497,24 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
if callsign and callsign not in aprs_stations:
|
||||
aprs_station_count += 1
|
||||
|
||||
# Update station data
|
||||
# Update station data, preserving last known coordinates when
|
||||
# packets do not contain position fields.
|
||||
if callsign:
|
||||
existing = aprs_stations.get(callsign, {})
|
||||
packet_lat = packet.get('lat')
|
||||
packet_lon = packet.get('lon')
|
||||
aprs_stations[callsign] = {
|
||||
'callsign': callsign,
|
||||
'lat': packet.get('lat'),
|
||||
'lon': packet.get('lon'),
|
||||
'symbol': packet.get('symbol'),
|
||||
'lat': packet_lat if packet_lat is not None else existing.get('lat'),
|
||||
'lon': packet_lon if packet_lon is not None else existing.get('lon'),
|
||||
'symbol': packet.get('symbol') or existing.get('symbol'),
|
||||
'last_seen': packet.get('timestamp'),
|
||||
'packet_type': packet.get('packet_type'),
|
||||
}
|
||||
# Geofence check
|
||||
_aprs_lat = packet.get('lat')
|
||||
_aprs_lon = packet.get('lon')
|
||||
if _aprs_lat and _aprs_lon:
|
||||
_aprs_lat = packet_lat
|
||||
_aprs_lon = packet_lon
|
||||
if _aprs_lat is not None and _aprs_lon is not None:
|
||||
try:
|
||||
from utils.geofence import get_geofence_manager
|
||||
for _gf_evt in get_geofence_manager().check_position(
|
||||
@@ -1416,7 +1547,6 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
logger.error(f"APRS stream error: {e}")
|
||||
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global aprs_active_device
|
||||
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
# Cleanup processes
|
||||
for proc in [rtl_process, decoder_process]:
|
||||
@@ -1428,9 +1558,9 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release SDR device
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
# Release SDR device — only if it's still ours (not reclaimed by a new start)
|
||||
if my_device is not None and aprs_active_device == my_device:
|
||||
app_module.release_sdr_device(my_device)
|
||||
aprs_active_device = None
|
||||
|
||||
|
||||
@@ -1478,6 +1608,24 @@ def get_stations() -> Response:
|
||||
})
|
||||
|
||||
|
||||
@aprs_bp.route('/data')
|
||||
def aprs_data() -> Response:
|
||||
"""Get APRS data snapshot for remote controller polling compatibility."""
|
||||
running = False
|
||||
if app_module.aprs_process:
|
||||
running = app_module.aprs_process.poll() is None
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'running': running,
|
||||
'stations': list(aprs_stations.values()),
|
||||
'count': len(aprs_stations),
|
||||
'packet_count': aprs_packet_count,
|
||||
'station_count': aprs_station_count,
|
||||
'last_packet_time': aprs_last_packet_time,
|
||||
})
|
||||
|
||||
|
||||
@aprs_bp.route('/start', methods=['POST'])
|
||||
def start_aprs() -> Response:
|
||||
"""Start APRS decoder."""
|
||||
@@ -1763,25 +1911,25 @@ def stop_aprs() -> Response:
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@aprs_bp.route('/stream')
|
||||
def stream_aprs() -> Response:
|
||||
"""SSE stream for APRS packets."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('aprs', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.aprs_queue,
|
||||
channel_key='aprs',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
@aprs_bp.route('/stream')
|
||||
def stream_aprs() -> Response:
|
||||
"""SSE stream for APRS packets."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('aprs', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.aprs_queue,
|
||||
channel_key='aprs',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@aprs_bp.route('/frequencies')
|
||||
|
||||
+27
-23
@@ -65,14 +65,17 @@ def auto_connect_gps():
|
||||
If gpsd is not running, attempts to detect GPS devices and start gpsd.
|
||||
Returns current status if already connected.
|
||||
"""
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
position = reader.position
|
||||
sky = reader.sky
|
||||
return jsonify({
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
# Ensure stream callbacks are attached for this process.
|
||||
reader.add_callback(_position_callback)
|
||||
reader.add_sky_callback(_sky_callback)
|
||||
position = reader.position
|
||||
sky = reader.sky
|
||||
return jsonify({
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
'has_fix': position is not None,
|
||||
'position': position.to_dict() if position else None,
|
||||
'sky': sky.to_dict() if sky else None,
|
||||
@@ -204,21 +207,22 @@ def get_position():
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/satellites')
|
||||
def get_satellites():
|
||||
"""Get current satellite sky view data."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
if not reader or not reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'GPS client not running'
|
||||
}), 400
|
||||
|
||||
sky = reader.sky
|
||||
if sky:
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
@gps_bp.route('/satellites')
|
||||
def get_satellites():
|
||||
"""Get current satellite sky view data."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
if not reader or not reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'waiting',
|
||||
'running': False,
|
||||
'message': 'GPS client not running'
|
||||
})
|
||||
|
||||
sky = reader.sky
|
||||
if sky:
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'sky': sky.to_dict()
|
||||
})
|
||||
else:
|
||||
|
||||
+478
-420
@@ -38,15 +38,15 @@ receiver_bp = Blueprint('receiver', __name__, url_prefix='/receiver')
|
||||
# ============================================
|
||||
|
||||
# Audio demodulation state
|
||||
audio_process = None
|
||||
audio_rtl_process = None
|
||||
audio_lock = threading.Lock()
|
||||
audio_start_lock = threading.Lock()
|
||||
audio_running = False
|
||||
audio_frequency = 0.0
|
||||
audio_modulation = 'fm'
|
||||
audio_source = 'process'
|
||||
audio_start_token = 0
|
||||
audio_process = None
|
||||
audio_rtl_process = None
|
||||
audio_lock = threading.Lock()
|
||||
audio_start_lock = threading.Lock()
|
||||
audio_running = False
|
||||
audio_frequency = 0.0
|
||||
audio_modulation = 'fm'
|
||||
audio_source = 'process'
|
||||
audio_start_token = 0
|
||||
|
||||
# Scanner state
|
||||
scanner_thread: Optional[threading.Thread] = None
|
||||
@@ -665,12 +665,21 @@ def scanner_loop_power():
|
||||
logger.info("Power sweep scanner thread stopped")
|
||||
|
||||
|
||||
def _start_audio_stream(frequency: float, modulation: str):
|
||||
def _start_audio_stream(
|
||||
frequency: float,
|
||||
modulation: str,
|
||||
*,
|
||||
device: int | None = None,
|
||||
sdr_type: str | None = None,
|
||||
gain: int | None = None,
|
||||
squelch: int | None = None,
|
||||
bias_t: bool | None = None,
|
||||
):
|
||||
"""Start audio streaming at given frequency."""
|
||||
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation
|
||||
|
||||
# Stop existing stream and snapshot config under lock
|
||||
with audio_lock:
|
||||
# Stop any existing stream
|
||||
_stop_audio_stream_internal()
|
||||
|
||||
ffmpeg_path = find_ffmpeg()
|
||||
@@ -678,209 +687,222 @@ def _start_audio_stream(frequency: float, modulation: str):
|
||||
logger.error("ffmpeg not found")
|
||||
return
|
||||
|
||||
# Determine SDR type and build appropriate command
|
||||
sdr_type_str = scanner_config.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
# Snapshot runtime tuning config so the spawned demod command cannot
|
||||
# drift if shared scanner_config changes while startup is in-flight.
|
||||
device_index = int(device if device is not None else scanner_config.get('device', 0))
|
||||
gain_value = int(gain if gain is not None else scanner_config.get('gain', 40))
|
||||
squelch_value = int(squelch if squelch is not None else scanner_config.get('squelch', 0))
|
||||
bias_t_enabled = bool(scanner_config.get('bias_t', False) if bias_t is None else bias_t)
|
||||
sdr_type_str = str(sdr_type if sdr_type is not None else scanner_config.get('sdr_type', 'rtlsdr')).lower()
|
||||
|
||||
# Set sample rates based on modulation
|
||||
if modulation == 'wfm':
|
||||
sample_rate = 170000
|
||||
resample_rate = 32000
|
||||
elif modulation in ['usb', 'lsb']:
|
||||
sample_rate = 12000
|
||||
resample_rate = 12000
|
||||
else:
|
||||
sample_rate = 24000
|
||||
resample_rate = 24000
|
||||
# Build commands outside lock (no blocking I/O, just command construction)
|
||||
try:
|
||||
resolved_sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
resolved_sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# Build the SDR command based on device type
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
# Use rtl_fm for RTL-SDR devices
|
||||
rtl_fm_path = find_rtl_fm()
|
||||
if not rtl_fm_path:
|
||||
logger.error("rtl_fm not found")
|
||||
return
|
||||
# Set sample rates based on modulation
|
||||
if modulation == 'wfm':
|
||||
sample_rate = 170000
|
||||
resample_rate = 32000
|
||||
elif modulation in ['usb', 'lsb']:
|
||||
sample_rate = 12000
|
||||
resample_rate = 12000
|
||||
else:
|
||||
sample_rate = 24000
|
||||
resample_rate = 24000
|
||||
|
||||
freq_hz = int(frequency * 1e6)
|
||||
sdr_cmd = [
|
||||
rtl_fm_path,
|
||||
'-M', _rtl_fm_demod_mode(modulation),
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(scanner_config['gain']),
|
||||
'-d', str(scanner_config['device']),
|
||||
'-l', str(scanner_config['squelch']),
|
||||
]
|
||||
if scanner_config.get('bias_t', False):
|
||||
sdr_cmd.append('-T')
|
||||
# Omit explicit filename: rtl_fm defaults to stdout.
|
||||
# (Some builds intermittently stall when '-' is passed explicitly.)
|
||||
else:
|
||||
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
|
||||
rx_fm_path = find_rx_fm()
|
||||
if not rx_fm_path:
|
||||
logger.error(f"rx_fm not found - required for {sdr_type.value}. Install SoapySDR utilities.")
|
||||
return
|
||||
# Build the SDR command based on device type
|
||||
if resolved_sdr_type == SDRType.RTL_SDR:
|
||||
rtl_fm_path = find_rtl_fm()
|
||||
if not rtl_fm_path:
|
||||
logger.error("rtl_fm not found")
|
||||
return
|
||||
|
||||
# Create device and get command builder
|
||||
device = SDRFactory.create_default_device(sdr_type, index=scanner_config['device'])
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
|
||||
# Build FM demod command
|
||||
sdr_cmd = builder.build_fm_demod_command(
|
||||
device=device,
|
||||
frequency_mhz=frequency,
|
||||
sample_rate=resample_rate,
|
||||
gain=float(scanner_config['gain']),
|
||||
modulation=modulation,
|
||||
squelch=scanner_config['squelch'],
|
||||
bias_t=scanner_config.get('bias_t', False)
|
||||
)
|
||||
# Ensure we use the found rx_fm path
|
||||
sdr_cmd[0] = rx_fm_path
|
||||
|
||||
encoder_cmd = [
|
||||
ffmpeg_path,
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-fflags', 'nobuffer',
|
||||
'-flags', 'low_delay',
|
||||
'-probesize', '32',
|
||||
'-analyzeduration', '0',
|
||||
'-f', 's16le',
|
||||
'-ar', str(resample_rate),
|
||||
'-ac', '1',
|
||||
'-i', 'pipe:0',
|
||||
'-acodec', 'pcm_s16le',
|
||||
'-ar', '44100',
|
||||
'-f', 'wav',
|
||||
'pipe:1'
|
||||
freq_hz = int(frequency * 1e6)
|
||||
sdr_cmd = [
|
||||
rtl_fm_path,
|
||||
'-M', _rtl_fm_demod_mode(modulation),
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(gain_value),
|
||||
'-d', str(device_index),
|
||||
'-l', str(squelch_value),
|
||||
]
|
||||
if bias_t_enabled:
|
||||
sdr_cmd.append('-T')
|
||||
else:
|
||||
rx_fm_path = find_rx_fm()
|
||||
if not rx_fm_path:
|
||||
logger.error(f"rx_fm not found - required for {resolved_sdr_type.value}. Install SoapySDR utilities.")
|
||||
return
|
||||
|
||||
try:
|
||||
# Use subprocess piping for reliable streaming.
|
||||
# Log stderr to temp files for error diagnosis.
|
||||
rtl_stderr_log = '/tmp/rtl_fm_stderr.log'
|
||||
ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log'
|
||||
logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}")
|
||||
sdr_device = SDRFactory.create_default_device(resolved_sdr_type, index=device_index)
|
||||
builder = SDRFactory.get_builder(resolved_sdr_type)
|
||||
sdr_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=frequency,
|
||||
sample_rate=resample_rate,
|
||||
gain=float(gain_value),
|
||||
modulation=modulation,
|
||||
squelch=squelch_value,
|
||||
bias_t=bias_t_enabled,
|
||||
)
|
||||
sdr_cmd[0] = rx_fm_path
|
||||
|
||||
# Retry loop for USB device contention (device may not be
|
||||
# released immediately after a previous process exits)
|
||||
max_attempts = 3
|
||||
for attempt in range(max_attempts):
|
||||
audio_rtl_process = None
|
||||
audio_process = None
|
||||
rtl_err_handle = None
|
||||
ffmpeg_err_handle = None
|
||||
try:
|
||||
rtl_err_handle = open(rtl_stderr_log, 'w')
|
||||
ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w')
|
||||
audio_rtl_process = subprocess.Popen(
|
||||
sdr_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=rtl_err_handle,
|
||||
bufsize=0,
|
||||
start_new_session=True # Create new process group for clean shutdown
|
||||
)
|
||||
audio_process = subprocess.Popen(
|
||||
encoder_cmd,
|
||||
stdin=audio_rtl_process.stdout,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=ffmpeg_err_handle,
|
||||
bufsize=0,
|
||||
start_new_session=True # Create new process group for clean shutdown
|
||||
)
|
||||
if audio_rtl_process.stdout:
|
||||
audio_rtl_process.stdout.close()
|
||||
finally:
|
||||
if rtl_err_handle:
|
||||
rtl_err_handle.close()
|
||||
if ffmpeg_err_handle:
|
||||
ffmpeg_err_handle.close()
|
||||
encoder_cmd = [
|
||||
ffmpeg_path,
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-fflags', 'nobuffer',
|
||||
'-flags', 'low_delay',
|
||||
'-probesize', '32',
|
||||
'-analyzeduration', '0',
|
||||
'-f', 's16le',
|
||||
'-ar', str(resample_rate),
|
||||
'-ac', '1',
|
||||
'-i', 'pipe:0',
|
||||
'-acodec', 'pcm_s16le',
|
||||
'-ar', '44100',
|
||||
'-f', 'wav',
|
||||
'pipe:1'
|
||||
]
|
||||
|
||||
# Brief delay to check if process started successfully
|
||||
time.sleep(0.3)
|
||||
# Retry loop outside lock — spawning + health check sleeps don't block
|
||||
# other operations. audio_start_lock already serializes callers.
|
||||
try:
|
||||
rtl_stderr_log = '/tmp/rtl_fm_stderr.log'
|
||||
ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log'
|
||||
logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={device_index}")
|
||||
|
||||
if (audio_rtl_process and audio_rtl_process.poll() is not None) or (
|
||||
audio_process and audio_process.poll() is not None
|
||||
):
|
||||
# Read stderr from temp files
|
||||
rtl_stderr = ''
|
||||
ffmpeg_stderr = ''
|
||||
try:
|
||||
with open(rtl_stderr_log, 'r') as f:
|
||||
rtl_stderr = f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with open(ffmpeg_stderr_log, 'r') as f:
|
||||
ffmpeg_stderr = f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
new_rtl_proc = None
|
||||
new_audio_proc = None
|
||||
max_attempts = 3
|
||||
for attempt in range(max_attempts):
|
||||
new_rtl_proc = None
|
||||
new_audio_proc = None
|
||||
rtl_err_handle = None
|
||||
ffmpeg_err_handle = None
|
||||
try:
|
||||
rtl_err_handle = open(rtl_stderr_log, 'w')
|
||||
ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w')
|
||||
new_rtl_proc = subprocess.Popen(
|
||||
sdr_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=rtl_err_handle,
|
||||
bufsize=0,
|
||||
start_new_session=True
|
||||
)
|
||||
new_audio_proc = subprocess.Popen(
|
||||
encoder_cmd,
|
||||
stdin=new_rtl_proc.stdout,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=ffmpeg_err_handle,
|
||||
bufsize=0,
|
||||
start_new_session=True
|
||||
)
|
||||
if new_rtl_proc.stdout:
|
||||
new_rtl_proc.stdout.close()
|
||||
finally:
|
||||
if rtl_err_handle:
|
||||
rtl_err_handle.close()
|
||||
if ffmpeg_err_handle:
|
||||
ffmpeg_err_handle.close()
|
||||
|
||||
if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1:
|
||||
logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...")
|
||||
if audio_process:
|
||||
try:
|
||||
audio_process.terminate()
|
||||
audio_process.wait(timeout=0.5)
|
||||
except Exception:
|
||||
pass
|
||||
if audio_rtl_process:
|
||||
try:
|
||||
audio_rtl_process.terminate()
|
||||
audio_rtl_process.wait(timeout=0.5)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1.0)
|
||||
continue
|
||||
# Brief delay to check if process started successfully
|
||||
time.sleep(0.3)
|
||||
|
||||
if audio_process and audio_process.poll() is None:
|
||||
try:
|
||||
audio_process.terminate()
|
||||
audio_process.wait(timeout=0.5)
|
||||
except Exception:
|
||||
pass
|
||||
if audio_rtl_process and audio_rtl_process.poll() is None:
|
||||
try:
|
||||
audio_rtl_process.terminate()
|
||||
audio_rtl_process.wait(timeout=0.5)
|
||||
except Exception:
|
||||
pass
|
||||
audio_process = None
|
||||
audio_rtl_process = None
|
||||
|
||||
logger.error(
|
||||
f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}"
|
||||
)
|
||||
return
|
||||
|
||||
# Pipeline started successfully
|
||||
break
|
||||
|
||||
# Keep monitor startup tolerant: some demod chains can take
|
||||
# several seconds before producing stream bytes.
|
||||
if (
|
||||
not audio_process
|
||||
or not audio_rtl_process
|
||||
or audio_process.poll() is not None
|
||||
or audio_rtl_process.poll() is not None
|
||||
if (new_rtl_proc and new_rtl_proc.poll() is not None) or (
|
||||
new_audio_proc and new_audio_proc.poll() is not None
|
||||
):
|
||||
logger.warning("Audio pipeline did not remain alive after startup")
|
||||
_stop_audio_stream_internal()
|
||||
rtl_stderr = ''
|
||||
ffmpeg_stderr = ''
|
||||
try:
|
||||
with open(rtl_stderr_log, 'r') as f:
|
||||
rtl_stderr = f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with open(ffmpeg_stderr_log, 'r') as f:
|
||||
ffmpeg_stderr = f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1:
|
||||
logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...")
|
||||
if new_audio_proc:
|
||||
try:
|
||||
new_audio_proc.terminate()
|
||||
new_audio_proc.wait(timeout=0.5)
|
||||
except Exception:
|
||||
pass
|
||||
if new_rtl_proc:
|
||||
try:
|
||||
new_rtl_proc.terminate()
|
||||
new_rtl_proc.wait(timeout=0.5)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1.0)
|
||||
continue
|
||||
|
||||
if new_audio_proc and new_audio_proc.poll() is None:
|
||||
try:
|
||||
new_audio_proc.terminate()
|
||||
new_audio_proc.wait(timeout=0.5)
|
||||
except Exception:
|
||||
pass
|
||||
if new_rtl_proc and new_rtl_proc.poll() is None:
|
||||
try:
|
||||
new_rtl_proc.terminate()
|
||||
new_rtl_proc.wait(timeout=0.5)
|
||||
except Exception:
|
||||
pass
|
||||
new_audio_proc = None
|
||||
new_rtl_proc = None
|
||||
|
||||
logger.error(
|
||||
f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}"
|
||||
)
|
||||
return
|
||||
|
||||
# Pipeline started successfully
|
||||
break
|
||||
|
||||
# Verify pipeline is still alive, then install under lock
|
||||
if (
|
||||
not new_audio_proc
|
||||
or not new_rtl_proc
|
||||
or new_audio_proc.poll() is not None
|
||||
or new_rtl_proc.poll() is not None
|
||||
):
|
||||
logger.warning("Audio pipeline did not remain alive after startup")
|
||||
# Clean up failed processes
|
||||
if new_audio_proc:
|
||||
try:
|
||||
new_audio_proc.terminate()
|
||||
new_audio_proc.wait(timeout=0.5)
|
||||
except Exception:
|
||||
pass
|
||||
if new_rtl_proc:
|
||||
try:
|
||||
new_rtl_proc.terminate()
|
||||
new_rtl_proc.wait(timeout=0.5)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Install processes under lock
|
||||
with audio_lock:
|
||||
audio_rtl_process = new_rtl_proc
|
||||
audio_process = new_audio_proc
|
||||
audio_running = True
|
||||
audio_frequency = frequency
|
||||
audio_modulation = modulation
|
||||
logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {sdr_type.value}")
|
||||
logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {resolved_sdr_type.value}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start audio stream: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start audio stream: {e}")
|
||||
|
||||
|
||||
def _stop_audio_stream():
|
||||
@@ -1271,202 +1293,223 @@ def get_presets() -> Response:
|
||||
# MANUAL AUDIO ENDPOINTS (for direct listening)
|
||||
# ============================================
|
||||
|
||||
@receiver_bp.route('/audio/start', methods=['POST'])
|
||||
def start_audio() -> Response:
|
||||
"""Start audio at specific frequency (manual mode)."""
|
||||
global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread
|
||||
global audio_running, audio_frequency, audio_modulation, audio_source, audio_start_token
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
try:
|
||||
frequency = float(data.get('frequency', 0))
|
||||
modulation = normalize_modulation(data.get('modulation', 'wfm'))
|
||||
squelch = int(data.get('squelch', 0))
|
||||
gain = int(data.get('gain', 40))
|
||||
device = int(data.get('device', 0))
|
||||
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
request_token_raw = data.get('request_token')
|
||||
request_token = int(request_token_raw) if request_token_raw is not None else None
|
||||
bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False))
|
||||
if isinstance(bias_t_raw, str):
|
||||
bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'}
|
||||
else:
|
||||
bias_t = bool(bias_t_raw)
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid parameter: {e}'
|
||||
}), 400
|
||||
|
||||
if frequency <= 0:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'frequency is required'
|
||||
}), 400
|
||||
|
||||
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
|
||||
if sdr_type not in valid_sdr_types:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
|
||||
}), 400
|
||||
|
||||
with audio_start_lock:
|
||||
if request_token is not None:
|
||||
if request_token < audio_start_token:
|
||||
return jsonify({
|
||||
'status': 'stale',
|
||||
'message': 'Superseded audio start request',
|
||||
'source': audio_source,
|
||||
'superseded': True,
|
||||
}), 409
|
||||
audio_start_token = request_token
|
||||
else:
|
||||
audio_start_token += 1
|
||||
request_token = audio_start_token
|
||||
|
||||
# Stop scanner if running
|
||||
if scanner_running:
|
||||
scanner_running = False
|
||||
if scanner_active_device is not None:
|
||||
app_module.release_sdr_device(scanner_active_device)
|
||||
scanner_active_device = None
|
||||
if scanner_thread and scanner_thread.is_alive():
|
||||
try:
|
||||
scanner_thread.join(timeout=2.0)
|
||||
except Exception:
|
||||
pass
|
||||
if scanner_power_process and scanner_power_process.poll() is None:
|
||||
try:
|
||||
scanner_power_process.terminate()
|
||||
scanner_power_process.wait(timeout=1)
|
||||
except Exception:
|
||||
try:
|
||||
scanner_power_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
scanner_power_process = None
|
||||
try:
|
||||
subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
|
||||
# Update config for audio
|
||||
scanner_config['squelch'] = squelch
|
||||
scanner_config['gain'] = gain
|
||||
scanner_config['device'] = device
|
||||
scanner_config['sdr_type'] = sdr_type
|
||||
scanner_config['bias_t'] = bias_t
|
||||
|
||||
# Preferred path: when waterfall WebSocket is active on the same SDR,
|
||||
# derive monitor audio from that IQ stream instead of spawning rtl_fm.
|
||||
try:
|
||||
from routes.waterfall_websocket import (
|
||||
get_shared_capture_status,
|
||||
start_shared_monitor_from_capture,
|
||||
)
|
||||
|
||||
shared = get_shared_capture_status()
|
||||
if shared.get('running') and shared.get('device') == device:
|
||||
_stop_audio_stream()
|
||||
ok, msg = start_shared_monitor_from_capture(
|
||||
device=device,
|
||||
frequency_mhz=frequency,
|
||||
modulation=modulation,
|
||||
squelch=squelch,
|
||||
)
|
||||
if ok:
|
||||
audio_running = True
|
||||
audio_frequency = frequency
|
||||
audio_modulation = modulation
|
||||
audio_source = 'waterfall'
|
||||
# Shared monitor uses the waterfall's existing SDR claim.
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
receiver_active_device = None
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'modulation': modulation,
|
||||
'source': 'waterfall',
|
||||
'request_token': request_token,
|
||||
})
|
||||
logger.warning(f"Shared waterfall monitor unavailable: {msg}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Shared waterfall monitor probe failed: {e}")
|
||||
|
||||
# Stop waterfall if it's using the same SDR (SSE path)
|
||||
if waterfall_running and waterfall_active_device == device:
|
||||
_stop_waterfall_internal()
|
||||
time.sleep(0.2)
|
||||
|
||||
# Claim device for listening audio. The WebSocket waterfall handler
|
||||
# may still be tearing down its IQ capture process (thread join +
|
||||
# safe_terminate can take several seconds), so we retry with back-off
|
||||
# to give the USB device time to be fully released.
|
||||
if receiver_active_device is None or receiver_active_device != device:
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
receiver_active_device = None
|
||||
|
||||
error = None
|
||||
max_claim_attempts = 6
|
||||
for attempt in range(max_claim_attempts):
|
||||
error = app_module.claim_sdr_device(device, 'receiver')
|
||||
if not error:
|
||||
break
|
||||
if attempt < max_claim_attempts - 1:
|
||||
logger.debug(
|
||||
f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
|
||||
f"failed, retrying in 0.5s: {error}"
|
||||
)
|
||||
time.sleep(0.5)
|
||||
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
receiver_active_device = device
|
||||
|
||||
_start_audio_stream(frequency, modulation)
|
||||
|
||||
if audio_running:
|
||||
audio_source = 'process'
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': audio_frequency,
|
||||
'modulation': audio_modulation,
|
||||
'source': 'process',
|
||||
'request_token': request_token,
|
||||
})
|
||||
|
||||
# Avoid leaving a stale device claim after startup failure.
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
receiver_active_device = None
|
||||
|
||||
start_error = ''
|
||||
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
|
||||
try:
|
||||
with open(log_path, 'r') as handle:
|
||||
content = handle.read().strip()
|
||||
if content:
|
||||
start_error = content.splitlines()[-1]
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
message = 'Failed to start audio. Check SDR device.'
|
||||
if start_error:
|
||||
message = f'Failed to start audio: {start_error}'
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': message
|
||||
}), 500
|
||||
@receiver_bp.route('/audio/start', methods=['POST'])
|
||||
def start_audio() -> Response:
|
||||
"""Start audio at specific frequency (manual mode)."""
|
||||
global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread
|
||||
global audio_running, audio_frequency, audio_modulation, audio_source, audio_start_token
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
try:
|
||||
frequency = float(data.get('frequency', 0))
|
||||
modulation = normalize_modulation(data.get('modulation', 'wfm'))
|
||||
squelch = int(data.get('squelch', 0))
|
||||
gain = int(data.get('gain', 40))
|
||||
device = int(data.get('device', 0))
|
||||
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
request_token_raw = data.get('request_token')
|
||||
request_token = int(request_token_raw) if request_token_raw is not None else None
|
||||
bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False))
|
||||
if isinstance(bias_t_raw, str):
|
||||
bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'}
|
||||
else:
|
||||
bias_t = bool(bias_t_raw)
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid parameter: {e}'
|
||||
}), 400
|
||||
|
||||
if frequency <= 0:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'frequency is required'
|
||||
}), 400
|
||||
|
||||
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
|
||||
if sdr_type not in valid_sdr_types:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
|
||||
}), 400
|
||||
|
||||
with audio_start_lock:
|
||||
if request_token is not None:
|
||||
if request_token < audio_start_token:
|
||||
return jsonify({
|
||||
'status': 'stale',
|
||||
'message': 'Superseded audio start request',
|
||||
'source': audio_source,
|
||||
'superseded': True,
|
||||
'current_token': audio_start_token,
|
||||
}), 409
|
||||
audio_start_token = request_token
|
||||
else:
|
||||
audio_start_token += 1
|
||||
request_token = audio_start_token
|
||||
|
||||
# Grab scanner refs inside lock, signal stop, clear state
|
||||
need_scanner_teardown = False
|
||||
scanner_thread_ref = None
|
||||
scanner_proc_ref = None
|
||||
if scanner_running:
|
||||
scanner_running = False
|
||||
if scanner_active_device is not None:
|
||||
app_module.release_sdr_device(scanner_active_device)
|
||||
scanner_active_device = None
|
||||
scanner_thread_ref = scanner_thread
|
||||
scanner_proc_ref = scanner_power_process
|
||||
scanner_power_process = None
|
||||
need_scanner_teardown = True
|
||||
|
||||
# Update config for audio
|
||||
scanner_config['squelch'] = squelch
|
||||
scanner_config['gain'] = gain
|
||||
scanner_config['device'] = device
|
||||
scanner_config['sdr_type'] = sdr_type
|
||||
scanner_config['bias_t'] = bias_t
|
||||
|
||||
# Scanner teardown outside lock (blocking: thread join, process wait, pkill, sleep)
|
||||
if need_scanner_teardown:
|
||||
if scanner_thread_ref and scanner_thread_ref.is_alive():
|
||||
try:
|
||||
scanner_thread_ref.join(timeout=2.0)
|
||||
except Exception:
|
||||
pass
|
||||
if scanner_proc_ref and scanner_proc_ref.poll() is None:
|
||||
try:
|
||||
scanner_proc_ref.terminate()
|
||||
scanner_proc_ref.wait(timeout=1)
|
||||
except Exception:
|
||||
try:
|
||||
scanner_proc_ref.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
|
||||
# Re-acquire lock for waterfall check and device claim
|
||||
with audio_start_lock:
|
||||
|
||||
# Preferred path: when waterfall WebSocket is active on the same SDR,
|
||||
# derive monitor audio from that IQ stream instead of spawning rtl_fm.
|
||||
try:
|
||||
from routes.waterfall_websocket import (
|
||||
get_shared_capture_status,
|
||||
start_shared_monitor_from_capture,
|
||||
)
|
||||
|
||||
shared = get_shared_capture_status()
|
||||
if shared.get('running') and shared.get('device') == device:
|
||||
_stop_audio_stream()
|
||||
ok, msg = start_shared_monitor_from_capture(
|
||||
device=device,
|
||||
frequency_mhz=frequency,
|
||||
modulation=modulation,
|
||||
squelch=squelch,
|
||||
)
|
||||
if ok:
|
||||
audio_running = True
|
||||
audio_frequency = frequency
|
||||
audio_modulation = modulation
|
||||
audio_source = 'waterfall'
|
||||
# Shared monitor uses the waterfall's existing SDR claim.
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
receiver_active_device = None
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'modulation': modulation,
|
||||
'source': 'waterfall',
|
||||
'request_token': request_token,
|
||||
})
|
||||
logger.warning(f"Shared waterfall monitor unavailable: {msg}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Shared waterfall monitor probe failed: {e}")
|
||||
|
||||
# Stop waterfall if it's using the same SDR (SSE path)
|
||||
if waterfall_running and waterfall_active_device == device:
|
||||
_stop_waterfall_internal()
|
||||
time.sleep(0.2)
|
||||
|
||||
# Claim device for listening audio. The WebSocket waterfall handler
|
||||
# may still be tearing down its IQ capture process (thread join +
|
||||
# safe_terminate can take several seconds), so we retry with back-off
|
||||
# to give the USB device time to be fully released.
|
||||
if receiver_active_device is None or receiver_active_device != device:
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
receiver_active_device = None
|
||||
|
||||
error = None
|
||||
max_claim_attempts = 6
|
||||
for attempt in range(max_claim_attempts):
|
||||
error = app_module.claim_sdr_device(device, 'receiver')
|
||||
if not error:
|
||||
break
|
||||
if attempt < max_claim_attempts - 1:
|
||||
logger.debug(
|
||||
f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
|
||||
f"failed, retrying in 0.5s: {error}"
|
||||
)
|
||||
time.sleep(0.5)
|
||||
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
receiver_active_device = device
|
||||
|
||||
_start_audio_stream(
|
||||
frequency,
|
||||
modulation,
|
||||
device=device,
|
||||
sdr_type=sdr_type,
|
||||
gain=gain,
|
||||
squelch=squelch,
|
||||
bias_t=bias_t,
|
||||
)
|
||||
|
||||
if audio_running:
|
||||
audio_source = 'process'
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': audio_frequency,
|
||||
'modulation': audio_modulation,
|
||||
'source': 'process',
|
||||
'request_token': request_token,
|
||||
})
|
||||
|
||||
# Avoid leaving a stale device claim after startup failure.
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
receiver_active_device = None
|
||||
|
||||
start_error = ''
|
||||
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
|
||||
try:
|
||||
with open(log_path, 'r') as handle:
|
||||
content = handle.read().strip()
|
||||
if content:
|
||||
start_error = content.splitlines()[-1]
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
message = 'Failed to start audio. Check SDR device.'
|
||||
if start_error:
|
||||
message = f'Failed to start audio: {start_error}'
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': message
|
||||
}), 500
|
||||
|
||||
|
||||
@receiver_bp.route('/audio/stop', methods=['POST'])
|
||||
@@ -1584,6 +1627,17 @@ def audio_probe() -> Response:
|
||||
@receiver_bp.route('/audio/stream')
|
||||
def stream_audio() -> Response:
|
||||
"""Stream WAV audio."""
|
||||
request_token_raw = request.args.get('request_token')
|
||||
request_token = None
|
||||
if request_token_raw is not None:
|
||||
try:
|
||||
request_token = int(request_token_raw)
|
||||
except (ValueError, TypeError):
|
||||
request_token = None
|
||||
|
||||
if request_token is not None and request_token < audio_start_token:
|
||||
return Response(b'', mimetype='audio/wav', status=204)
|
||||
|
||||
if audio_source == 'waterfall':
|
||||
for _ in range(40):
|
||||
if audio_running:
|
||||
@@ -1593,39 +1647,41 @@ def stream_audio() -> Response:
|
||||
if not audio_running:
|
||||
return Response(b'', mimetype='audio/wav', status=204)
|
||||
|
||||
def generate_shared():
|
||||
global audio_running, audio_source
|
||||
try:
|
||||
from routes.waterfall_websocket import (
|
||||
get_shared_capture_status,
|
||||
def generate_shared():
|
||||
global audio_running, audio_source
|
||||
try:
|
||||
from routes.waterfall_websocket import (
|
||||
get_shared_capture_status,
|
||||
read_shared_monitor_audio_chunk,
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# Browser expects an immediate WAV header.
|
||||
yield _wav_header(sample_rate=48000)
|
||||
inactive_since: float | None = None
|
||||
|
||||
while audio_running and audio_source == 'waterfall':
|
||||
chunk = read_shared_monitor_audio_chunk(timeout=1.0)
|
||||
if chunk:
|
||||
inactive_since = None
|
||||
yield chunk
|
||||
continue
|
||||
shared = get_shared_capture_status()
|
||||
if shared.get('running') and shared.get('monitor_enabled'):
|
||||
inactive_since = None
|
||||
continue
|
||||
if inactive_since is None:
|
||||
inactive_since = time.monotonic()
|
||||
continue
|
||||
if (time.monotonic() - inactive_since) < 4.0:
|
||||
continue
|
||||
if not shared.get('running') or not shared.get('monitor_enabled'):
|
||||
audio_running = False
|
||||
audio_source = 'process'
|
||||
break
|
||||
# Browser expects an immediate WAV header.
|
||||
yield _wav_header(sample_rate=48000)
|
||||
inactive_since: float | None = None
|
||||
|
||||
while audio_running and audio_source == 'waterfall':
|
||||
if request_token is not None and request_token < audio_start_token:
|
||||
break
|
||||
chunk = read_shared_monitor_audio_chunk(timeout=1.0)
|
||||
if chunk:
|
||||
inactive_since = None
|
||||
yield chunk
|
||||
continue
|
||||
shared = get_shared_capture_status()
|
||||
if shared.get('running') and shared.get('monitor_enabled'):
|
||||
inactive_since = None
|
||||
continue
|
||||
if inactive_since is None:
|
||||
inactive_since = time.monotonic()
|
||||
continue
|
||||
if (time.monotonic() - inactive_since) < 4.0:
|
||||
continue
|
||||
if not shared.get('running') or not shared.get('monitor_enabled'):
|
||||
audio_running = False
|
||||
audio_source = 'process'
|
||||
break
|
||||
|
||||
return Response(
|
||||
generate_shared(),
|
||||
@@ -1674,6 +1730,8 @@ def stream_audio() -> Response:
|
||||
first_chunk_deadline = time.time() + 20.0
|
||||
warned_wait = False
|
||||
while audio_running and proc.poll() is None:
|
||||
if request_token is not None and request_token < audio_start_token:
|
||||
break
|
||||
# Use select to avoid blocking forever
|
||||
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
||||
if ready:
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
"""CW/Morse code decoder routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
import app as app_module
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.morse import morse_decoder_thread
|
||||
from utils.process import register_process, safe_terminate, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.validation import (
|
||||
validate_device_index,
|
||||
validate_frequency,
|
||||
validate_gain,
|
||||
validate_ppm,
|
||||
)
|
||||
|
||||
morse_bp = Blueprint('morse', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
morse_active_device: int | None = None
|
||||
|
||||
|
||||
def _validate_tone_freq(value: Any) -> float:
|
||||
"""Validate CW tone frequency (300-1200 Hz)."""
|
||||
try:
|
||||
freq = float(value)
|
||||
if not 300 <= freq <= 1200:
|
||||
raise ValueError("Tone frequency must be between 300 and 1200 Hz")
|
||||
return freq
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Invalid tone frequency: {value}") from e
|
||||
|
||||
|
||||
def _validate_wpm(value: Any) -> int:
|
||||
"""Validate words per minute (5-50)."""
|
||||
try:
|
||||
wpm = int(value)
|
||||
if not 5 <= wpm <= 50:
|
||||
raise ValueError("WPM must be between 5 and 50")
|
||||
return wpm
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Invalid WPM: {value}") from e
|
||||
|
||||
|
||||
@morse_bp.route('/morse/start', methods=['POST'])
|
||||
def start_morse() -> Response:
|
||||
global morse_active_device
|
||||
|
||||
with app_module.morse_lock:
|
||||
if app_module.morse_process:
|
||||
return jsonify({'status': 'error', 'message': 'Morse decoder already running'}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate standard SDR inputs
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '14.060'))
|
||||
gain = validate_gain(data.get('gain', '0'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Validate Morse-specific inputs
|
||||
try:
|
||||
tone_freq = _validate_tone_freq(data.get('tone_freq', '700'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
try:
|
||||
wpm = _validate_wpm(data.get('wpm', '15'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Claim SDR device
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'morse')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
morse_active_device = device_int
|
||||
|
||||
# Clear queue
|
||||
while not app_module.morse_queue.empty():
|
||||
try:
|
||||
app_module.morse_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Build rtl_fm USB demodulation command
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
sample_rate = 8000
|
||||
bias_t = data.get('bias_t', False)
|
||||
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
sample_rate=sample_rate,
|
||||
gain=float(gain) if gain and gain != '0' else None,
|
||||
ppm=int(ppm) if ppm and ppm != '0' else None,
|
||||
modulation='usb',
|
||||
bias_t=bias_t,
|
||||
)
|
||||
|
||||
full_cmd = ' '.join(rtl_cmd)
|
||||
logger.info(f"Morse decoder running: {full_cmd}")
|
||||
|
||||
try:
|
||||
rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
register_process(rtl_process)
|
||||
|
||||
# Monitor rtl_fm stderr
|
||||
def monitor_stderr():
|
||||
for line in rtl_process.stderr:
|
||||
err_text = line.decode('utf-8', errors='replace').strip()
|
||||
if err_text:
|
||||
logger.debug(f"[rtl_fm/morse] {err_text}")
|
||||
|
||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||
stderr_thread.daemon = True
|
||||
stderr_thread.start()
|
||||
|
||||
# Start Morse decoder thread
|
||||
stop_event = threading.Event()
|
||||
decoder_thread = threading.Thread(
|
||||
target=morse_decoder_thread,
|
||||
args=(
|
||||
rtl_process.stdout,
|
||||
app_module.morse_queue,
|
||||
stop_event,
|
||||
sample_rate,
|
||||
tone_freq,
|
||||
wpm,
|
||||
),
|
||||
)
|
||||
decoder_thread.daemon = True
|
||||
decoder_thread.start()
|
||||
|
||||
app_module.morse_process = rtl_process
|
||||
app_module.morse_process._stop_decoder = stop_event
|
||||
app_module.morse_process._decoder_thread = decoder_thread
|
||||
|
||||
app_module.morse_queue.put({'type': 'status', 'status': 'started'})
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'command': full_cmd,
|
||||
'tone_freq': tone_freq,
|
||||
'wpm': wpm,
|
||||
})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
if morse_active_device is not None:
|
||||
app_module.release_sdr_device(morse_active_device)
|
||||
morse_active_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}), 400
|
||||
|
||||
except Exception as e:
|
||||
# Clean up rtl_fm if it was started
|
||||
try:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=2)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
rtl_process.kill()
|
||||
unregister_process(rtl_process)
|
||||
if morse_active_device is not None:
|
||||
app_module.release_sdr_device(morse_active_device)
|
||||
morse_active_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@morse_bp.route('/morse/stop', methods=['POST'])
|
||||
def stop_morse() -> Response:
|
||||
global morse_active_device
|
||||
|
||||
with app_module.morse_lock:
|
||||
if app_module.morse_process:
|
||||
# Signal decoder thread to stop
|
||||
stop_event = getattr(app_module.morse_process, '_stop_decoder', None)
|
||||
if stop_event:
|
||||
stop_event.set()
|
||||
|
||||
safe_terminate(app_module.morse_process)
|
||||
unregister_process(app_module.morse_process)
|
||||
app_module.morse_process = None
|
||||
|
||||
if morse_active_device is not None:
|
||||
app_module.release_sdr_device(morse_active_device)
|
||||
morse_active_device = None
|
||||
|
||||
app_module.morse_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@morse_bp.route('/morse/status')
|
||||
def morse_status() -> Response:
|
||||
with app_module.morse_lock:
|
||||
running = (
|
||||
app_module.morse_process is not None
|
||||
and app_module.morse_process.poll() is None
|
||||
)
|
||||
return jsonify({'running': running})
|
||||
|
||||
|
||||
@morse_bp.route('/morse/stream')
|
||||
def morse_stream() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('morse', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.morse_queue,
|
||||
channel_key='morse',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
+34
-21
@@ -138,36 +138,34 @@ def start_rtlamr() -> Response:
|
||||
output_format = data.get('format', 'json')
|
||||
|
||||
# Start rtl_tcp first
|
||||
rtl_tcp_just_started = False
|
||||
rtl_tcp_cmd_str = ''
|
||||
with rtl_tcp_lock:
|
||||
if not rtl_tcp_process:
|
||||
logger.info("Starting rtl_tcp server...")
|
||||
try:
|
||||
rtl_tcp_cmd = ['rtl_tcp', '-a', '0.0.0.0']
|
||||
|
||||
|
||||
# Add device index if not 0
|
||||
if device and device != '0':
|
||||
rtl_tcp_cmd.extend(['-d', str(device)])
|
||||
|
||||
|
||||
# Add gain if not auto
|
||||
if gain and gain != '0':
|
||||
rtl_tcp_cmd.extend(['-g', str(gain)])
|
||||
|
||||
|
||||
# Add PPM correction if not 0
|
||||
if ppm and ppm != '0':
|
||||
rtl_tcp_cmd.extend(['-p', str(ppm)])
|
||||
|
||||
|
||||
rtl_tcp_process = subprocess.Popen(
|
||||
rtl_tcp_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(rtl_tcp_process)
|
||||
|
||||
# Wait a moment for rtl_tcp to start
|
||||
time.sleep(3)
|
||||
|
||||
logger.info(f"rtl_tcp started: {' '.join(rtl_tcp_cmd)}")
|
||||
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
|
||||
rtl_tcp_just_started = True
|
||||
rtl_tcp_cmd_str = ' '.join(rtl_tcp_cmd)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start rtl_tcp: {e}")
|
||||
# Release SDR device on rtl_tcp failure
|
||||
@@ -176,6 +174,12 @@ def start_rtlamr() -> Response:
|
||||
rtlamr_active_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
|
||||
|
||||
# Wait for rtl_tcp to start outside lock
|
||||
if rtl_tcp_just_started:
|
||||
time.sleep(3)
|
||||
logger.info(f"rtl_tcp started: {rtl_tcp_cmd_str}")
|
||||
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {rtl_tcp_cmd_str}'})
|
||||
|
||||
# Build rtlamr command
|
||||
cmd = [
|
||||
'rtlamr',
|
||||
@@ -258,25 +262,34 @@ def start_rtlamr() -> Response:
|
||||
def stop_rtlamr() -> Response:
|
||||
global rtl_tcp_process, rtlamr_active_device
|
||||
|
||||
# Grab process refs inside locks, clear state, then terminate outside
|
||||
rtlamr_proc = None
|
||||
with app_module.rtlamr_lock:
|
||||
if app_module.rtlamr_process:
|
||||
app_module.rtlamr_process.terminate()
|
||||
try:
|
||||
app_module.rtlamr_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.rtlamr_process.kill()
|
||||
rtlamr_proc = app_module.rtlamr_process
|
||||
app_module.rtlamr_process = None
|
||||
|
||||
if rtlamr_proc:
|
||||
rtlamr_proc.terminate()
|
||||
try:
|
||||
rtlamr_proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
rtlamr_proc.kill()
|
||||
|
||||
# Also stop rtl_tcp
|
||||
tcp_proc = None
|
||||
with rtl_tcp_lock:
|
||||
if rtl_tcp_process:
|
||||
rtl_tcp_process.terminate()
|
||||
try:
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
rtl_tcp_process.kill()
|
||||
tcp_proc = rtl_tcp_process
|
||||
rtl_tcp_process = None
|
||||
logger.info("rtl_tcp stopped")
|
||||
|
||||
if tcp_proc:
|
||||
tcp_proc.terminate()
|
||||
try:
|
||||
tcp_proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
tcp_proc.kill()
|
||||
logger.info("rtl_tcp stopped")
|
||||
|
||||
# Release device from registry
|
||||
if rtlamr_active_device is not None:
|
||||
|
||||
+71
-44
@@ -1345,7 +1345,7 @@ def _scan_rf_signals(
|
||||
sweep_ranges: list[dict] | None = None
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Scan for RF signals using SDR (rtl_power).
|
||||
Scan for RF signals using SDR (rtl_power or hackrf_sweep).
|
||||
|
||||
Scans common surveillance frequency bands:
|
||||
- 88-108 MHz: FM broadcast (potential FM bugs)
|
||||
@@ -1375,39 +1375,50 @@ def _scan_rf_signals(
|
||||
|
||||
logger.info(f"Starting RF scan (device={sdr_device})")
|
||||
|
||||
# Detect available SDR devices and sweep tools
|
||||
rtl_power_path = shutil.which('rtl_power')
|
||||
if not rtl_power_path:
|
||||
logger.warning("rtl_power not found in PATH, RF scanning unavailable")
|
||||
hackrf_sweep_path = shutil.which('hackrf_sweep')
|
||||
|
||||
sdr_type = None
|
||||
sweep_tool_path = None
|
||||
|
||||
try:
|
||||
from utils.sdr import SDRFactory
|
||||
from utils.sdr.base import SDRType
|
||||
devices = SDRFactory.detect_devices()
|
||||
rtlsdr_available = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
|
||||
hackrf_available = any(d.sdr_type == SDRType.HACKRF for d in devices)
|
||||
except ImportError:
|
||||
rtlsdr_available = False
|
||||
hackrf_available = False
|
||||
|
||||
# Pick the best available SDR + sweep tool combo
|
||||
if rtlsdr_available and rtl_power_path:
|
||||
sdr_type = 'rtlsdr'
|
||||
sweep_tool_path = rtl_power_path
|
||||
logger.info(f"Using RTL-SDR with rtl_power at: {rtl_power_path}")
|
||||
elif hackrf_available and hackrf_sweep_path:
|
||||
sdr_type = 'hackrf'
|
||||
sweep_tool_path = hackrf_sweep_path
|
||||
logger.info(f"Using HackRF with hackrf_sweep at: {hackrf_sweep_path}")
|
||||
elif rtl_power_path:
|
||||
# Tool exists but no device detected — try anyway (detection may have failed)
|
||||
sdr_type = 'rtlsdr'
|
||||
sweep_tool_path = rtl_power_path
|
||||
logger.info(f"No SDR detected but rtl_power found, attempting RTL-SDR scan")
|
||||
elif hackrf_sweep_path:
|
||||
sdr_type = 'hackrf'
|
||||
sweep_tool_path = hackrf_sweep_path
|
||||
logger.info(f"No SDR detected but hackrf_sweep found, attempting HackRF scan")
|
||||
|
||||
if not sweep_tool_path:
|
||||
logger.warning("No supported sweep tool found (rtl_power or hackrf_sweep)")
|
||||
_emit_event('rf_status', {
|
||||
'status': 'error',
|
||||
'message': 'rtl_power not installed. Install rtl-sdr package for RF scanning.',
|
||||
'message': 'No SDR sweep tool installed. Install rtl-sdr (rtl_power) or HackRF (hackrf_sweep) for RF scanning.',
|
||||
})
|
||||
return signals
|
||||
|
||||
logger.info(f"Found rtl_power at: {rtl_power_path}")
|
||||
|
||||
# Test if RTL-SDR device is accessible
|
||||
rtl_test_path = shutil.which('rtl_test')
|
||||
if rtl_test_path:
|
||||
try:
|
||||
test_result = subprocess.run(
|
||||
[rtl_test_path, '-t'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if 'No supported devices found' in test_result.stderr or test_result.returncode != 0:
|
||||
logger.warning("No RTL-SDR device found")
|
||||
_emit_event('rf_status', {
|
||||
'status': 'error',
|
||||
'message': 'No RTL-SDR device connected. Connect an RTL-SDR dongle for RF scanning.',
|
||||
})
|
||||
return signals
|
||||
except subprocess.TimeoutExpired:
|
||||
pass # Device might be busy, continue anyway
|
||||
except Exception as e:
|
||||
logger.debug(f"rtl_test check failed: {e}")
|
||||
|
||||
# Define frequency bands to scan (in Hz)
|
||||
# Format: (start_freq, end_freq, bin_size, description)
|
||||
scan_bands: list[tuple[int, int, int, str]] = []
|
||||
@@ -1448,7 +1459,7 @@ def _scan_rf_signals(
|
||||
|
||||
try:
|
||||
# Build device argument
|
||||
device_arg = ['-d', str(sdr_device if sdr_device is not None else 0)]
|
||||
device_idx = sdr_device if sdr_device is not None else 0
|
||||
|
||||
# Scan each band and look for strong signals
|
||||
for start_freq, end_freq, bin_size, band_name in scan_bands:
|
||||
@@ -1458,15 +1469,27 @@ def _scan_rf_signals(
|
||||
logger.info(f"Scanning {band_name} ({start_freq/1e6:.1f}-{end_freq/1e6:.1f} MHz)")
|
||||
|
||||
try:
|
||||
# Run rtl_power for a quick sweep of this band
|
||||
cmd = [
|
||||
rtl_power_path,
|
||||
'-f', f'{start_freq}:{end_freq}:{bin_size}',
|
||||
'-g', '40', # Gain
|
||||
'-i', '1', # Integration interval (1 second)
|
||||
'-1', # Single shot mode
|
||||
'-c', '20%', # Crop 20% of edges
|
||||
] + device_arg + [tmp_path]
|
||||
# Build sweep command based on SDR type
|
||||
if sdr_type == 'hackrf':
|
||||
cmd = [
|
||||
sweep_tool_path,
|
||||
'-f', f'{int(start_freq / 1e6)}:{int(end_freq / 1e6)}',
|
||||
'-w', str(bin_size),
|
||||
'-1', # Single sweep
|
||||
]
|
||||
output_mode = 'stdout'
|
||||
else:
|
||||
cmd = [
|
||||
sweep_tool_path,
|
||||
'-f', f'{start_freq}:{end_freq}:{bin_size}',
|
||||
'-g', '40', # Gain
|
||||
'-i', '1', # Integration interval (1 second)
|
||||
'-1', # Single shot mode
|
||||
'-c', '20%', # Crop 20% of edges
|
||||
'-d', str(device_idx),
|
||||
tmp_path,
|
||||
]
|
||||
output_mode = 'file'
|
||||
|
||||
logger.debug(f"Running: {' '.join(cmd)}")
|
||||
|
||||
@@ -1478,9 +1501,14 @@ def _scan_rf_signals(
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.warning(f"rtl_power returned {result.returncode}: {result.stderr}")
|
||||
logger.warning(f"{os.path.basename(sweep_tool_path)} returned {result.returncode}: {result.stderr}")
|
||||
|
||||
# Parse the CSV output
|
||||
# For HackRF, write stdout CSV data to temp file for unified parsing
|
||||
if output_mode == 'stdout' and result.stdout:
|
||||
with open(tmp_path, 'w') as f:
|
||||
f.write(result.stdout)
|
||||
|
||||
# Parse the CSV output (same format for both rtl_power and hackrf_sweep)
|
||||
if os.path.exists(tmp_path) and os.path.getsize(tmp_path) > 0:
|
||||
with open(tmp_path, 'r') as f:
|
||||
for line in f:
|
||||
@@ -1488,13 +1516,12 @@ def _scan_rf_signals(
|
||||
if len(parts) >= 7:
|
||||
try:
|
||||
# CSV format: date, time, hz_low, hz_high, hz_step, samples, db_values...
|
||||
hz_low = int(parts[2])
|
||||
hz_high = int(parts[3])
|
||||
hz_step = float(parts[4])
|
||||
hz_low = int(parts[2].strip())
|
||||
hz_high = int(parts[3].strip())
|
||||
hz_step = float(parts[4].strip())
|
||||
db_values = [float(x) for x in parts[6:] if x.strip()]
|
||||
|
||||
# Find peaks above noise floor
|
||||
# RTL-SDR dongles have higher noise figures, so use permissive thresholds
|
||||
noise_floor = sum(db_values) / len(db_values) if db_values else -100
|
||||
threshold = noise_floor + 6 # Signal must be 6dB above noise
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -544,6 +547,16 @@ def init_waterfall_websocket(app: Flask):
|
||||
}))
|
||||
continue
|
||||
|
||||
# Pre-flight: check the capture binary exists
|
||||
if not shutil.which(iq_cmd[0]):
|
||||
app_module.release_sdr_device(device_index)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Required tool "{iq_cmd[0]}" not found. Install SoapySDR tools (rx_sdr).',
|
||||
}))
|
||||
continue
|
||||
|
||||
# Spawn I/Q capture process (retry to handle USB release lag)
|
||||
max_attempts = 3 if was_restarting else 1
|
||||
try:
|
||||
@@ -556,7 +569,7 @@ def init_waterfall_websocket(app: Flask):
|
||||
iq_process = subprocess.Popen(
|
||||
iq_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=0,
|
||||
)
|
||||
register_process(iq_process)
|
||||
@@ -564,17 +577,23 @@ def init_waterfall_websocket(app: Flask):
|
||||
# Brief check that process started
|
||||
time.sleep(0.3)
|
||||
if iq_process.poll() is not None:
|
||||
stderr_out = ''
|
||||
if iq_process.stderr:
|
||||
with suppress(Exception):
|
||||
stderr_out = iq_process.stderr.read().decode('utf-8', errors='replace').strip()
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if attempt < max_attempts - 1:
|
||||
logger.info(
|
||||
f"I/Q process exited immediately, "
|
||||
f"retrying ({attempt + 1}/{max_attempts})..."
|
||||
+ (f" stderr: {stderr_out}" if stderr_out else "")
|
||||
)
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
detail = f": {stderr_out}" if stderr_out else ""
|
||||
raise RuntimeError(
|
||||
"I/Q capture process exited immediately"
|
||||
f"I/Q capture process exited immediately{detail}"
|
||||
)
|
||||
break # Process started successfully
|
||||
except Exception as e:
|
||||
|
||||
+57
-35
@@ -18,6 +18,7 @@ from utils.weather_sat import (
|
||||
is_weather_sat_available,
|
||||
CaptureProgress,
|
||||
WEATHER_SATELLITES,
|
||||
DEFAULT_SAMPLE_RATE,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.weather_sat')
|
||||
@@ -40,6 +41,35 @@ def _progress_callback(progress: CaptureProgress) -> None:
|
||||
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')
|
||||
def get_status():
|
||||
"""Get weather satellite decoder status.
|
||||
@@ -152,18 +182,15 @@ def start_capture():
|
||||
decoder.set_callback(_progress_callback)
|
||||
|
||||
def _release_device():
|
||||
try:
|
||||
import app as app_module
|
||||
app_module.release_sdr_device(device_index)
|
||||
except ImportError:
|
||||
pass
|
||||
_release_weather_sat_device(device_index)
|
||||
|
||||
decoder.set_on_complete(_release_device)
|
||||
|
||||
success = decoder.start(
|
||||
success, error_msg = decoder.start(
|
||||
satellite=satellite,
|
||||
device_index=device_index,
|
||||
gain=gain,
|
||||
sample_rate=DEFAULT_SAMPLE_RATE,
|
||||
bias_t=bias_t,
|
||||
)
|
||||
|
||||
@@ -181,7 +208,7 @@ def start_capture():
|
||||
_release_device()
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start capture'
|
||||
'message': error_msg or 'Failed to start capture'
|
||||
}), 500
|
||||
|
||||
|
||||
@@ -283,7 +310,7 @@ def test_decode():
|
||||
decoder.set_callback(_progress_callback)
|
||||
decoder.set_on_complete(None)
|
||||
|
||||
success = decoder.start_from_file(
|
||||
success, error_msg = decoder.start_from_file(
|
||||
satellite=satellite,
|
||||
input_file=input_file,
|
||||
sample_rate=sample_rate,
|
||||
@@ -302,7 +329,7 @@ def test_decode():
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start file decode'
|
||||
'message': error_msg or 'Failed to start file decode'
|
||||
}), 500
|
||||
|
||||
|
||||
@@ -318,12 +345,7 @@ def stop_capture():
|
||||
|
||||
decoder.stop()
|
||||
|
||||
# Release SDR device
|
||||
try:
|
||||
import app as app_module
|
||||
app_module.release_sdr_device(device_index)
|
||||
except ImportError:
|
||||
pass
|
||||
_release_weather_sat_device(device_index)
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -563,26 +585,26 @@ def enable_schedule():
|
||||
'message': 'Invalid parameter value'
|
||||
}), 400
|
||||
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||
|
||||
try:
|
||||
result = scheduler.enable(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
min_elevation=min_elev,
|
||||
device=device,
|
||||
gain=gain_val,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to enable weather sat scheduler")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to enable scheduler'
|
||||
}), 500
|
||||
|
||||
return jsonify({'status': 'ok', **result})
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||
|
||||
try:
|
||||
result = scheduler.enable(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
min_elevation=min_elev,
|
||||
device=device,
|
||||
gain=gain_val,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to enable weather sat scheduler")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to enable scheduler'
|
||||
}), 500
|
||||
|
||||
return jsonify({'status': 'ok', **result})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/schedule/disable', methods=['POST'])
|
||||
|
||||
+514
@@ -0,0 +1,514 @@
|
||||
"""WeFax (Weather Fax) decoder routes.
|
||||
|
||||
Provides endpoints for decoding HF weather fax transmissions from
|
||||
maritime/aviation weather services worldwide.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sdr import SDRType
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.validation import validate_frequency
|
||||
from utils.wefax import get_wefax_decoder
|
||||
from utils.wefax_stations import (
|
||||
WEFAX_USB_ALIGNMENT_OFFSET_KHZ,
|
||||
get_current_broadcasts,
|
||||
get_station,
|
||||
load_stations,
|
||||
resolve_tuning_frequency_khz,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.wefax')
|
||||
|
||||
wefax_bp = Blueprint('wefax', __name__, url_prefix='/wefax')
|
||||
|
||||
# SSE progress queue
|
||||
_wefax_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
# Track active SDR device
|
||||
wefax_active_device: int | None = None
|
||||
|
||||
|
||||
def _progress_callback(data: dict) -> None:
|
||||
"""Callback to queue progress updates for SSE stream."""
|
||||
global wefax_active_device
|
||||
|
||||
try:
|
||||
_wefax_queue.put_nowait(data)
|
||||
except queue.Full:
|
||||
try:
|
||||
_wefax_queue.get_nowait()
|
||||
_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')
|
||||
def get_status():
|
||||
"""Get WeFax decoder status."""
|
||||
decoder = get_wefax_decoder()
|
||||
return jsonify({
|
||||
'available': True,
|
||||
'running': decoder.is_running,
|
||||
'image_count': len(decoder.get_images()),
|
||||
})
|
||||
|
||||
|
||||
@wefax_bp.route('/start', methods=['POST'])
|
||||
def start_decoder():
|
||||
"""Start WeFax decoder.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"frequency_khz": 4298,
|
||||
"station": "NOJ",
|
||||
"device": 0,
|
||||
"gain": 40,
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"direct_sampling": true,
|
||||
"frequency_reference": "auto" // auto, carrier, or dial
|
||||
}
|
||||
"""
|
||||
decoder = get_wefax_decoder()
|
||||
|
||||
if decoder.is_running:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'message': 'WeFax decoder is already running',
|
||||
})
|
||||
|
||||
# Clear queue
|
||||
while not _wefax_queue.empty():
|
||||
try:
|
||||
_wefax_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# Validate frequency (required)
|
||||
frequency_khz = data.get('frequency_khz')
|
||||
if frequency_khz is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'frequency_khz is required',
|
||||
}), 400
|
||||
|
||||
try:
|
||||
frequency_khz = float(frequency_khz)
|
||||
# WeFax operates on HF: 2-30 MHz (2000-30000 kHz)
|
||||
freq_mhz = frequency_khz / 1000.0
|
||||
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||
except (TypeError, ValueError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid frequency: {e}',
|
||||
}), 400
|
||||
|
||||
station = str(data.get('station', '')).strip()
|
||||
device_index = data.get('device', 0)
|
||||
gain = float(data.get('gain', 40.0))
|
||||
ioc = int(data.get('ioc', 576))
|
||||
lpm = int(data.get('lpm', 120))
|
||||
direct_sampling = bool(data.get('direct_sampling', True))
|
||||
frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower()
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
if not frequency_reference:
|
||||
frequency_reference = 'auto'
|
||||
|
||||
try:
|
||||
tuned_frequency_khz, resolved_reference, usb_offset_applied = (
|
||||
resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=frequency_khz,
|
||||
station_callsign=station,
|
||||
frequency_reference=frequency_reference,
|
||||
)
|
||||
)
|
||||
tuned_mhz = tuned_frequency_khz / 1000.0
|
||||
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||
except ValueError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid frequency settings: {e}',
|
||||
}), 400
|
||||
|
||||
# Validate IOC and LPM
|
||||
if ioc not in (288, 576):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'IOC must be 288 or 576',
|
||||
}), 400
|
||||
|
||||
if lpm not in (60, 120):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'LPM must be 60 or 120',
|
||||
}), 400
|
||||
|
||||
# Claim SDR device
|
||||
global wefax_active_device
|
||||
device_int = int(device_index)
|
||||
error = app_module.claim_sdr_device(device_int, 'wefax')
|
||||
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(
|
||||
frequency_khz=tuned_frequency_khz,
|
||||
station=station,
|
||||
device_index=device_int,
|
||||
gain=gain,
|
||||
ioc=ioc,
|
||||
lpm=lpm,
|
||||
direct_sampling=direct_sampling,
|
||||
sdr_type=sdr_type_str,
|
||||
)
|
||||
|
||||
if success:
|
||||
wefax_active_device = device_int
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency_khz': frequency_khz,
|
||||
'tuned_frequency_khz': tuned_frequency_khz,
|
||||
'frequency_reference': resolved_reference,
|
||||
'usb_offset_applied': usb_offset_applied,
|
||||
'usb_offset_khz': (
|
||||
WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0
|
||||
),
|
||||
'station': station,
|
||||
'ioc': ioc,
|
||||
'lpm': lpm,
|
||||
'device': device_int,
|
||||
})
|
||||
else:
|
||||
app_module.release_sdr_device(device_int)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder',
|
||||
}), 500
|
||||
|
||||
|
||||
@wefax_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoder():
|
||||
"""Stop WeFax decoder."""
|
||||
global wefax_active_device
|
||||
decoder = get_wefax_decoder()
|
||||
decoder.stop()
|
||||
|
||||
if wefax_active_device is not None:
|
||||
app_module.release_sdr_device(wefax_active_device)
|
||||
wefax_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@wefax_bp.route('/stream')
|
||||
def stream_progress():
|
||||
"""SSE stream of WeFax decode progress."""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_wefax_queue,
|
||||
channel_key='wefax',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@wefax_bp.route('/images')
|
||||
def list_images():
|
||||
"""Get list of decoded WeFax images."""
|
||||
decoder = get_wefax_decoder()
|
||||
images = decoder.get_images()
|
||||
|
||||
limit = request.args.get('limit', type=int)
|
||||
if limit and limit > 0:
|
||||
images = images[-limit:]
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': [img.to_dict() for img in images],
|
||||
'count': len(images),
|
||||
})
|
||||
|
||||
|
||||
@wefax_bp.route('/images/<filename>')
|
||||
def get_image(filename: str):
|
||||
"""Get a decoded WeFax image file."""
|
||||
decoder = get_wefax_decoder()
|
||||
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
if not image_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
return send_file(image_path, mimetype='image/png')
|
||||
|
||||
|
||||
@wefax_bp.route('/images/<filename>', methods=['DELETE'])
|
||||
def delete_image(filename: str):
|
||||
"""Delete a decoded WeFax image."""
|
||||
decoder = get_wefax_decoder()
|
||||
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'ok'})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
|
||||
@wefax_bp.route('/images', methods=['DELETE'])
|
||||
def delete_all_images():
|
||||
"""Delete all decoded WeFax images."""
|
||||
decoder = get_wefax_decoder()
|
||||
count = decoder.delete_all_images()
|
||||
return jsonify({'status': 'ok', 'deleted': count})
|
||||
|
||||
|
||||
# ========================
|
||||
# Auto-Scheduler Endpoints
|
||||
# ========================
|
||||
|
||||
|
||||
def _scheduler_event_callback(event: dict) -> None:
|
||||
"""Forward scheduler events to the SSE queue."""
|
||||
try:
|
||||
_wefax_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
try:
|
||||
_wefax_queue.get_nowait()
|
||||
_wefax_queue.put_nowait(event)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
@wefax_bp.route('/schedule/enable', methods=['POST'])
|
||||
def enable_schedule():
|
||||
"""Enable auto-scheduling of WeFax broadcast captures.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"station": "NOJ",
|
||||
"frequency_khz": 4298,
|
||||
"device": 0,
|
||||
"gain": 40,
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"direct_sampling": true,
|
||||
"frequency_reference": "auto" // auto, carrier, or dial
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON with scheduler status.
|
||||
"""
|
||||
from utils.wefax_scheduler import get_wefax_scheduler
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
station = str(data.get('station', '')).strip()
|
||||
if not station:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'station is required',
|
||||
}), 400
|
||||
|
||||
frequency_khz = data.get('frequency_khz')
|
||||
if frequency_khz is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'frequency_khz is required',
|
||||
}), 400
|
||||
|
||||
try:
|
||||
frequency_khz = float(frequency_khz)
|
||||
freq_mhz = frequency_khz / 1000.0
|
||||
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||
except (TypeError, ValueError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid frequency: {e}',
|
||||
}), 400
|
||||
|
||||
device = int(data.get('device', 0))
|
||||
gain = float(data.get('gain', 40.0))
|
||||
ioc = int(data.get('ioc', 576))
|
||||
lpm = int(data.get('lpm', 120))
|
||||
direct_sampling = bool(data.get('direct_sampling', True))
|
||||
frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower()
|
||||
if not frequency_reference:
|
||||
frequency_reference = 'auto'
|
||||
|
||||
try:
|
||||
tuned_frequency_khz, resolved_reference, usb_offset_applied = (
|
||||
resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=frequency_khz,
|
||||
station_callsign=station,
|
||||
frequency_reference=frequency_reference,
|
||||
)
|
||||
)
|
||||
tuned_mhz = tuned_frequency_khz / 1000.0
|
||||
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||
except ValueError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid frequency settings: {e}',
|
||||
}), 400
|
||||
|
||||
scheduler = get_wefax_scheduler()
|
||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||
|
||||
try:
|
||||
result = scheduler.enable(
|
||||
station=station,
|
||||
frequency_khz=tuned_frequency_khz,
|
||||
device=device,
|
||||
gain=gain,
|
||||
ioc=ioc,
|
||||
lpm=lpm,
|
||||
direct_sampling=direct_sampling,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to enable WeFax scheduler")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to enable scheduler',
|
||||
}), 500
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
**result,
|
||||
'frequency_khz': frequency_khz,
|
||||
'tuned_frequency_khz': tuned_frequency_khz,
|
||||
'frequency_reference': resolved_reference,
|
||||
'usb_offset_applied': usb_offset_applied,
|
||||
'usb_offset_khz': (
|
||||
WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@wefax_bp.route('/schedule/disable', methods=['POST'])
|
||||
def disable_schedule():
|
||||
"""Disable auto-scheduling."""
|
||||
from utils.wefax_scheduler import get_wefax_scheduler
|
||||
|
||||
scheduler = get_wefax_scheduler()
|
||||
result = scheduler.disable()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@wefax_bp.route('/schedule/status')
|
||||
def schedule_status():
|
||||
"""Get current scheduler state."""
|
||||
from utils.wefax_scheduler import get_wefax_scheduler
|
||||
|
||||
scheduler = get_wefax_scheduler()
|
||||
return jsonify(scheduler.get_status())
|
||||
|
||||
|
||||
@wefax_bp.route('/schedule/broadcasts')
|
||||
def schedule_broadcasts():
|
||||
"""List scheduled broadcasts."""
|
||||
from utils.wefax_scheduler import get_wefax_scheduler
|
||||
|
||||
scheduler = get_wefax_scheduler()
|
||||
broadcasts = scheduler.get_broadcasts()
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'broadcasts': broadcasts,
|
||||
'count': len(broadcasts),
|
||||
})
|
||||
|
||||
|
||||
@wefax_bp.route('/schedule/skip/<broadcast_id>', methods=['POST'])
|
||||
def skip_broadcast(broadcast_id: str):
|
||||
"""Skip a scheduled broadcast."""
|
||||
from utils.wefax_scheduler import get_wefax_scheduler
|
||||
|
||||
if not broadcast_id.replace('_', '').replace('-', '').isalnum():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid broadcast ID',
|
||||
}), 400
|
||||
|
||||
scheduler = get_wefax_scheduler()
|
||||
if scheduler.skip_broadcast(broadcast_id):
|
||||
return jsonify({'status': 'skipped', 'broadcast_id': broadcast_id})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Broadcast not found or already processed',
|
||||
}), 404
|
||||
|
||||
|
||||
@wefax_bp.route('/stations')
|
||||
def list_stations():
|
||||
"""Get all WeFax stations from the database."""
|
||||
stations = load_stations()
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'stations': stations,
|
||||
'count': len(stations),
|
||||
})
|
||||
|
||||
|
||||
@wefax_bp.route('/stations/<callsign>')
|
||||
def station_detail(callsign: str):
|
||||
"""Get station detail including current schedule info."""
|
||||
station = get_station(callsign)
|
||||
if not station:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Station {callsign} not found',
|
||||
}), 404
|
||||
|
||||
current = get_current_broadcasts(callsign)
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'station': station,
|
||||
'current_broadcasts': current,
|
||||
})
|
||||
@@ -202,10 +202,38 @@ body {
|
||||
}
|
||||
|
||||
.welcome-container {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
max-width: 900px;
|
||||
z-index: 1;
|
||||
animation: welcomeFadeIn 0.8s ease-out;
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.welcome-settings-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 2;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-dim, rgba(255, 255, 255, 0.3));
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.welcome-settings-btn:hover {
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.welcome-settings-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes welcomeFadeIn {
|
||||
@@ -232,6 +260,7 @@ body {
|
||||
|
||||
.welcome-logo {
|
||||
animation: logoPulse 3s ease-in-out infinite;
|
||||
will-change: filter;
|
||||
}
|
||||
|
||||
@keyframes logoPulse {
|
||||
@@ -332,6 +361,7 @@ body {
|
||||
padding: 20px;
|
||||
max-height: calc(100vh - 300px);
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.changelog-release {
|
||||
@@ -1559,6 +1589,7 @@ header h1 .tagline {
|
||||
overflow: hidden;
|
||||
padding: 12px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
@@ -1744,6 +1775,7 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
.run-btn {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: var(--accent-green);
|
||||
@@ -1781,6 +1813,7 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
.stop-btn {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: var(--accent-red);
|
||||
|
||||
@@ -151,8 +151,17 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gps-sky-globe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#gpsSkyCanvas {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
@@ -166,10 +175,50 @@
|
||||
.gps-sky-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.gps-skyview-canvas-wrap.gps-sky-fallback .gps-sky-globe {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gps-skyview-canvas-wrap.gps-sky-fallback #gpsSkyCanvas,
|
||||
.gps-skyview-canvas-wrap.gps-sky-fallback .gps-sky-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.gps-globe-sat-icon {
|
||||
--sat-size: 18px;
|
||||
--sat-color: #8ea6bd;
|
||||
width: var(--sat-size);
|
||||
height: var(--sat-size);
|
||||
transform: translate(-50%, -50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--sat-color);
|
||||
background: radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.22), rgba(7, 14, 23, 0.82) 72%);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 0 12px var(--sat-color);
|
||||
}
|
||||
|
||||
.gps-globe-sat-icon img {
|
||||
width: 76%;
|
||||
height: 76%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.gps-globe-sat-icon.used {
|
||||
opacity: 0.98;
|
||||
}
|
||||
|
||||
.gps-globe-sat-icon.unused {
|
||||
opacity: 0.72;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 0 6px var(--sat-color);
|
||||
}
|
||||
|
||||
.gps-sky-label {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/* Morse Code / CW Decoder Styles */
|
||||
|
||||
/* Scope canvas container */
|
||||
.morse-scope-container {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.morse-scope-container canvas {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Decoded text panel */
|
||||
.morse-decoded-panel {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
min-height: 120px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 18px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.morse-decoded-panel:empty::before {
|
||||
content: 'Decoded text will appear here...';
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Individual decoded character with fade-in */
|
||||
.morse-char {
|
||||
display: inline;
|
||||
animation: morseFadeIn 0.3s ease-out;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes morseFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* Small Morse notation above character */
|
||||
.morse-char-morse {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 1px;
|
||||
display: block;
|
||||
line-height: 1;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
/* Reference grid */
|
||||
.morse-ref-grid {
|
||||
transition: max-height 0.3s ease, opacity 0.3s ease;
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.morse-ref-grid.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Toolbar: export/copy/clear */
|
||||
.morse-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.morse-toolbar .btn {
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
/* Status bar at bottom */
|
||||
.morse-status-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
padding: 6px 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.morse-status-bar .status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Word space styling */
|
||||
.morse-word-space {
|
||||
display: inline;
|
||||
width: 0.5em;
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
/* ============================================
|
||||
WeFax (Weather Fax) Mode Styles
|
||||
Amber/gold theme (#ffaa00) for HF
|
||||
============================================ */
|
||||
|
||||
/* Place WeFax sidebar panel above the shared SDR Device section
|
||||
while keeping the collapse button at the very top. */
|
||||
#wefaxMode.active {
|
||||
order: -1;
|
||||
}
|
||||
.sidebar:has(#wefaxMode.active) > .sidebar-collapse-btn {
|
||||
order: -2;
|
||||
}
|
||||
|
||||
/* --- Stats Strip --- */
|
||||
.wefax-stats-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-card, #0e1117);
|
||||
border: 1px solid var(--border-color, #1e2a3a);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wefax-strip-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wefax-strip-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wefax-strip-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #444;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wefax-strip-dot.scanning { background: #ffaa00; animation: wefax-pulse 1.5s ease-in-out infinite; }
|
||||
.wefax-strip-dot.phasing { background: #ffcc44; animation: wefax-pulse 0.8s ease-in-out infinite; }
|
||||
.wefax-strip-dot.receiving { background: #00cc66; animation: wefax-pulse 1s ease-in-out infinite; }
|
||||
.wefax-strip-dot.complete { background: #00cc66; }
|
||||
.wefax-strip-dot.error { background: #f44; }
|
||||
|
||||
@keyframes wefax-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.wefax-strip-status-text {
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.wefax-strip-btn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--border-color, #1e2a3a);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-primary, #161b22);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.wefax-strip-btn.start { color: #ffaa00; border-color: #ffaa0044; }
|
||||
.wefax-strip-btn.start:hover { background: #ffaa0015; border-color: #ffaa00; }
|
||||
.wefax-strip-btn.start.wefax-strip-btn-error {
|
||||
border-color: #ffaa00;
|
||||
color: #ffaa00;
|
||||
box-shadow: 0 0 8px rgba(255, 170, 0, 0.3);
|
||||
animation: wefax-pulse 0.6s ease-in-out 3;
|
||||
}
|
||||
|
||||
.wefax-strip-btn.stop { color: #f44; border-color: #f4444444; }
|
||||
.wefax-strip-btn.stop:hover { background: #f4441a; border-color: #f44; }
|
||||
|
||||
.wefax-strip-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border-color, #1e2a3a);
|
||||
}
|
||||
|
||||
.wefax-strip-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.wefax-strip-value {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wefax-strip-value.accent-amber { color: #ffaa00; }
|
||||
|
||||
.wefax-strip-label {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 8px;
|
||||
color: var(--text-dim, #555);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* --- Schedule Toggle --- */
|
||||
.wefax-schedule-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
color: var(--text-dim, #666);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.wefax-schedule-toggle input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
accent-color: #ffaa00;
|
||||
}
|
||||
|
||||
.wefax-schedule-toggle input:checked + span {
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
/* --- Visuals Container --- */
|
||||
.wefax-visuals-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* --- Main Row --- */
|
||||
.wefax-main-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* --- Schedule Timeline --- */
|
||||
.wefax-schedule-panel {
|
||||
background: var(--bg-card, #0e1117);
|
||||
border: 1px solid var(--border-color, #1e2a3a);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wefax-schedule-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #1e2a3a);
|
||||
}
|
||||
|
||||
.wefax-schedule-title {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.wefax-schedule-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wefax-schedule-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #1e2a3a)11;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wefax-schedule-entry:last-child { border-bottom: none; }
|
||||
|
||||
.wefax-schedule-entry.active {
|
||||
background: #ffaa0010;
|
||||
border-left: 3px solid #ffaa00;
|
||||
}
|
||||
|
||||
.wefax-schedule-entry.upcoming {
|
||||
background: #ffaa0008;
|
||||
}
|
||||
|
||||
.wefax-schedule-entry.past {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.wefax-schedule-time {
|
||||
color: #ffaa00;
|
||||
min-width: 45px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wefax-schedule-content {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wefax-schedule-badge {
|
||||
font-size: 9px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--border-color, #1e2a3a);
|
||||
color: var(--text-dim, #555);
|
||||
}
|
||||
|
||||
.wefax-schedule-badge.live {
|
||||
background: #ffaa0030;
|
||||
color: #ffaa00;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wefax-schedule-badge.soon {
|
||||
background: #ffaa0015;
|
||||
color: #ffcc66;
|
||||
}
|
||||
|
||||
.wefax-schedule-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-dim, #555);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
/* --- Live Section --- */
|
||||
.wefax-live-section {
|
||||
background: var(--bg-card, #0e1117);
|
||||
border: 1px solid var(--border-color, #1e2a3a);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wefax-live-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #1e2a3a);
|
||||
}
|
||||
|
||||
.wefax-live-title {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.wefax-live-content {
|
||||
padding: 12px;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wefax-idle-state {
|
||||
text-align: center;
|
||||
color: var(--text-dim, #555);
|
||||
}
|
||||
|
||||
.wefax-idle-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #ffaa0033;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.wefax-idle-state h4 {
|
||||
margin: 0 0 4px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wefax-idle-state p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wefax-live-preview {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
border-radius: 4px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* --- Gallery Section --- */
|
||||
.wefax-gallery-section {
|
||||
background: var(--bg-card, #0e1117);
|
||||
border: 1px solid var(--border-color, #1e2a3a);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wefax-gallery-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #1e2a3a);
|
||||
}
|
||||
|
||||
.wefax-gallery-title {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.wefax-gallery-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wefax-gallery-count {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #555);
|
||||
}
|
||||
|
||||
.wefax-gallery-clear-btn {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: none;
|
||||
border: 1px solid var(--border-color, #1e2a3a);
|
||||
color: var(--text-dim, #555);
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wefax-gallery-clear-btn:hover {
|
||||
border-color: #f44;
|
||||
color: #f44;
|
||||
}
|
||||
|
||||
.wefax-gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wefax-gallery-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-dim, #555);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.wefax-gallery-item {
|
||||
position: relative;
|
||||
background: var(--bg-primary, #161b22);
|
||||
border: 1px solid var(--border-color, #1e2a3a);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wefax-gallery-item img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wefax-gallery-item img:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.wefax-gallery-meta {
|
||||
padding: 4px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #555);
|
||||
}
|
||||
|
||||
.wefax-gallery-actions {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.wefax-gallery-item:hover .wefax-gallery-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wefax-gallery-action {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wefax-gallery-action:hover { color: #fff; }
|
||||
.wefax-gallery-action.delete:hover { color: #f44; }
|
||||
|
||||
/* --- Countdown Bar + Timeline --- */
|
||||
.wefax-countdown-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-secondary, #141820);
|
||||
border: 1px solid var(--border-color, #1e2a3a);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.wefax-countdown-next {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wefax-countdown-boxes {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wefax-countdown-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-primary, #0d1117);
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 4px;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.wefax-countdown-box.imminent {
|
||||
border-color: #ffaa00;
|
||||
box-shadow: 0 0 8px rgba(255, 170, 0, 0.2);
|
||||
}
|
||||
|
||||
.wefax-countdown-box.active {
|
||||
border-color: #ffaa00;
|
||||
box-shadow: 0 0 8px rgba(255, 170, 0, 0.3);
|
||||
animation: wefax-glow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes wefax-glow {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(255, 170, 0, 0.3); }
|
||||
50% { box-shadow: 0 0 16px rgba(255, 170, 0, 0.5); }
|
||||
}
|
||||
|
||||
.wefax-cd-value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wefax-cd-unit {
|
||||
font-size: 8px;
|
||||
color: var(--text-dim, #666);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.wefax-countdown-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.wefax-countdown-content {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #ffaa00;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.wefax-countdown-detail {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.wefax-timeline {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
height: 36px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.wefax-timeline-track {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 16px;
|
||||
background: var(--bg-primary, #0d1117);
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wefax-timeline-broadcast {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background: rgba(255, 170, 0, 0.5);
|
||||
border-radius: 2px;
|
||||
cursor: default;
|
||||
opacity: 0.8;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.wefax-timeline-broadcast:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wefax-timeline-broadcast.active {
|
||||
background: rgba(255, 170, 0, 0.85);
|
||||
border: 1px solid #ffaa00;
|
||||
}
|
||||
|
||||
.wefax-timeline-cursor {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
width: 2px;
|
||||
height: 20px;
|
||||
background: #ff4444;
|
||||
border-radius: 1px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.wefax-timeline-labels {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 8px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
/* --- Image Modal --- */
|
||||
.wefax-image-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.wefax-image-modal.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.wefax-image-modal img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wefax-modal-toolbar {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 60px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wefax-modal-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wefax-modal-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.wefax-modal-btn.delete:hover {
|
||||
background: var(--accent-red, #ff3366);
|
||||
border-color: var(--accent-red, #ff3366);
|
||||
}
|
||||
|
||||
.wefax-modal-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 32px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wefax-modal-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* --- Responsive --- */
|
||||
@media (max-width: 768px) {
|
||||
.wefax-main-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Satellite</title>
|
||||
<desc id="desc">Professional satellite icon with solar panels and body</desc>
|
||||
<defs>
|
||||
<linearGradient id="panelGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#344a5f"/>
|
||||
<stop offset="55%" stop-color="#233547"/>
|
||||
<stop offset="100%" stop-color="#1a2734"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="panelGrid" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#4f6a85" stop-opacity="0.7"/>
|
||||
<stop offset="100%" stop-color="#2b3f53" stop-opacity="0.1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bodyGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#8ea0b2"/>
|
||||
<stop offset="45%" stop-color="#6f8193"/>
|
||||
<stop offset="100%" stop-color="#536475"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="dishGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#7e91a4"/>
|
||||
<stop offset="100%" stop-color="#556779"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="8" y="42" width="38" height="44" rx="2.5" fill="url(#panelGradient)" stroke="#607891" stroke-width="2"/>
|
||||
<rect x="12" y="46" width="30" height="36" rx="1.5" fill="url(#panelGrid)" stroke="#405669" stroke-width="1.2"/>
|
||||
<path d="M21 46v36M30 46v36M12 55h30M12 64h30M12 73h30" stroke="#4a6278" stroke-width="0.9" opacity="0.82"/>
|
||||
|
||||
<rect x="82" y="42" width="38" height="44" rx="2.5" fill="url(#panelGradient)" stroke="#607891" stroke-width="2"/>
|
||||
<rect x="86" y="46" width="30" height="36" rx="1.5" fill="url(#panelGrid)" stroke="#405669" stroke-width="1.2"/>
|
||||
<path d="M95 46v36M104 46v36M86 55h30M86 64h30M86 73h30" stroke="#4a6278" stroke-width="0.9" opacity="0.82"/>
|
||||
|
||||
<line x1="46" y1="64" x2="52" y2="64" stroke="#8fa4b9" stroke-width="3"/>
|
||||
<line x1="76" y1="64" x2="82" y2="64" stroke="#8fa4b9" stroke-width="3"/>
|
||||
|
||||
<rect x="52" y="40" width="24" height="48" rx="4" fill="url(#bodyGradient)" stroke="#91a5b8" stroke-width="2"/>
|
||||
<rect x="55" y="53" width="18" height="4" rx="1.5" fill="#d2dce6" opacity="0.48"/>
|
||||
<rect x="55" y="62" width="18" height="4" rx="1.5" fill="#d2dce6" opacity="0.42"/>
|
||||
|
||||
<path d="M64 24c6 0 11 5 11 11s-5 11-11 11-11-5-11-11 5-11 11-11Z" fill="url(#dishGradient)" stroke="#95aac0" stroke-width="2"/>
|
||||
<circle cx="64" cy="35" r="3.2" fill="#d7e2ed" opacity="0.7"/>
|
||||
<path d="M58 26c2.2-2.4 9.8-2.4 12 0" fill="none" stroke="#a7b8c8" stroke-width="1.5"/>
|
||||
<line x1="64" y1="46" x2="64" y2="51" stroke="#9fb2c6" stroke-width="2"/>
|
||||
|
||||
<path d="M57 88L64 101L71 88Z" fill="url(#dishGradient)" stroke="#8fa4b8" stroke-width="1.8"/>
|
||||
<line x1="64" y1="101" x2="64" y2="108" stroke="#8fa4b8" stroke-width="2"/>
|
||||
<circle cx="64" cy="110" r="2.8" fill="#b9c9d8"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -7,6 +7,7 @@ const AlertCenter = (function() {
|
||||
let rules = [];
|
||||
let eventSource = null;
|
||||
let reconnectTimer = null;
|
||||
let lastConnectionWarningAt = 0;
|
||||
|
||||
function init() {
|
||||
loadRules();
|
||||
@@ -31,7 +32,14 @@ const AlertCenter = (function() {
|
||||
};
|
||||
|
||||
eventSource.onerror = function() {
|
||||
console.warn('[Alerts] SSE connection error');
|
||||
const now = Date.now();
|
||||
const offline = (typeof window.isOffline === 'function' && window.isOffline()) ||
|
||||
(typeof navigator !== 'undefined' && navigator.onLine === false);
|
||||
const shouldLog = !offline && !document.hidden && (now - lastConnectionWarningAt) > 15000;
|
||||
if (shouldLog) {
|
||||
lastConnectionWarningAt = now;
|
||||
console.warn('[Alerts] SSE connection error; retrying');
|
||||
}
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(connect, 2500);
|
||||
};
|
||||
|
||||
@@ -92,8 +92,9 @@ const RunState = (function() {
|
||||
renderHealth(data);
|
||||
} catch (err) {
|
||||
renderHealth(null, err);
|
||||
const transient = isTransientFailure(err);
|
||||
const now = Date.now();
|
||||
if (typeof reportActionableError === 'function' && (now - lastErrorToastAt) > 30000) {
|
||||
if (!transient && typeof reportActionableError === 'function' && (now - lastErrorToastAt) > 30000) {
|
||||
lastErrorToastAt = now;
|
||||
reportActionableError('Run State', err, { persistent: false });
|
||||
}
|
||||
@@ -214,6 +215,17 @@ const RunState = (function() {
|
||||
return String(err);
|
||||
}
|
||||
|
||||
function isTransientFailure(err) {
|
||||
if (typeof window.isTransientOrOffline === 'function' && window.isTransientOrOffline(err)) {
|
||||
return true;
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||
return true;
|
||||
}
|
||||
const text = extractMessage(err).toLowerCase();
|
||||
return text.includes('failed to fetch') || text.includes('network') || text.includes('timeout');
|
||||
}
|
||||
|
||||
function getLastHealth() {
|
||||
return lastHealth;
|
||||
}
|
||||
|
||||
@@ -1281,6 +1281,7 @@ function loadVoiceAlertConfig() {
|
||||
const pager = document.getElementById('voiceCfgPager');
|
||||
const tscm = document.getElementById('voiceCfgTscm');
|
||||
const tracker = document.getElementById('voiceCfgTracker');
|
||||
const military = document.getElementById('voiceCfgAdsbMilitary');
|
||||
const squawk = document.getElementById('voiceCfgSquawk');
|
||||
const rate = document.getElementById('voiceCfgRate');
|
||||
const pitch = document.getElementById('voiceCfgPitch');
|
||||
@@ -1290,6 +1291,7 @@ function loadVoiceAlertConfig() {
|
||||
if (pager) pager.checked = cfg.streams.pager !== false;
|
||||
if (tscm) tscm.checked = cfg.streams.tscm !== false;
|
||||
if (tracker) tracker.checked = cfg.streams.bluetooth !== false;
|
||||
if (military) military.checked = cfg.streams.adsb_military !== false;
|
||||
if (squawk) squawk.checked = cfg.streams.squawks !== false;
|
||||
if (rate) rate.value = cfg.rate;
|
||||
if (pitch) pitch.value = cfg.pitch;
|
||||
@@ -1314,10 +1316,11 @@ function saveVoiceAlertConfig() {
|
||||
pitch: parseFloat(document.getElementById('voiceCfgPitch')?.value) || 0.9,
|
||||
voiceName: document.getElementById('voiceCfgVoice')?.value || '',
|
||||
streams: {
|
||||
pager: !!document.getElementById('voiceCfgPager')?.checked,
|
||||
tscm: !!document.getElementById('voiceCfgTscm')?.checked,
|
||||
bluetooth: !!document.getElementById('voiceCfgTracker')?.checked,
|
||||
squawks: !!document.getElementById('voiceCfgSquawk')?.checked,
|
||||
pager: !!document.getElementById('voiceCfgPager')?.checked,
|
||||
tscm: !!document.getElementById('voiceCfgTscm')?.checked,
|
||||
bluetooth: !!document.getElementById('voiceCfgTracker')?.checked,
|
||||
adsb_military: !!document.getElementById('voiceCfgAdsbMilitary')?.checked,
|
||||
squawks: !!document.getElementById('voiceCfgSquawk')?.checked,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -208,9 +208,31 @@ const AppFeedback = (function() {
|
||||
return state;
|
||||
}
|
||||
|
||||
function isOffline() {
|
||||
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||
}
|
||||
|
||||
function isTransientNetworkError(error) {
|
||||
const text = String(extractMessage(error) || '').toLowerCase();
|
||||
if (!text) return false;
|
||||
|
||||
return text.includes('networkerror') ||
|
||||
text.includes('failed to fetch') ||
|
||||
text.includes('network request failed') ||
|
||||
text.includes('load failed') ||
|
||||
text.includes('err_network_io_suspended') ||
|
||||
text.includes('network io suspended') ||
|
||||
text.includes('the network connection was lost') ||
|
||||
text.includes('connection reset') ||
|
||||
text.includes('timeout');
|
||||
}
|
||||
|
||||
function isTransientOrOffline(error) {
|
||||
return isOffline() || isTransientNetworkError(error);
|
||||
}
|
||||
|
||||
function isNetworkError(message) {
|
||||
const text = String(message || '').toLowerCase();
|
||||
return text.includes('networkerror') || text.includes('failed to fetch') || text.includes('timeout');
|
||||
return isTransientNetworkError(message);
|
||||
}
|
||||
|
||||
function isSettingsError(message) {
|
||||
@@ -224,6 +246,9 @@ const AppFeedback = (function() {
|
||||
reportError,
|
||||
removeToast,
|
||||
renderCollectionState,
|
||||
isOffline,
|
||||
isTransientNetworkError,
|
||||
isTransientOrOffline,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -243,6 +268,18 @@ window.renderCollectionState = function(container, options) {
|
||||
return AppFeedback.renderCollectionState(container, options);
|
||||
};
|
||||
|
||||
window.isOffline = function() {
|
||||
return AppFeedback.isOffline();
|
||||
};
|
||||
|
||||
window.isTransientNetworkError = function(error) {
|
||||
return AppFeedback.isTransientNetworkError(error);
|
||||
};
|
||||
|
||||
window.isTransientOrOffline = function(error) {
|
||||
return AppFeedback.isTransientOrOffline(error);
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
AppFeedback.init();
|
||||
});
|
||||
|
||||
@@ -20,7 +20,13 @@ const VoiceAlerts = (function () {
|
||||
rate: 1.1,
|
||||
pitch: 0.9,
|
||||
voiceName: '',
|
||||
streams: { pager: true, tscm: true, bluetooth: true },
|
||||
streams: {
|
||||
pager: true,
|
||||
tscm: true,
|
||||
bluetooth: true,
|
||||
adsb_military: true,
|
||||
squawks: true,
|
||||
},
|
||||
};
|
||||
|
||||
function _toNumberInRange(value, fallback, min, max) {
|
||||
|
||||
+530
-68
@@ -9,22 +9,45 @@ const GPS = (function() {
|
||||
let lastPosition = null;
|
||||
let lastSky = null;
|
||||
let skyPollTimer = null;
|
||||
let statusPollTimer = null;
|
||||
let themeObserver = null;
|
||||
let skyRenderer = null;
|
||||
let skyRendererInitAttempted = false;
|
||||
|
||||
// Constellation color map
|
||||
const CONST_COLORS = {
|
||||
'GPS': '#00d4ff',
|
||||
'GLONASS': '#00ff88',
|
||||
let skyRendererInitPromise = null;
|
||||
|
||||
// Constellation color map
|
||||
const CONST_COLORS = {
|
||||
'GPS': '#00d4ff',
|
||||
'GLONASS': '#00ff88',
|
||||
'Galileo': '#ff8800',
|
||||
'BeiDou': '#ff4466',
|
||||
'SBAS': '#ffdd00',
|
||||
'QZSS': '#cc66ff',
|
||||
};
|
||||
|
||||
'SBAS': '#ffdd00',
|
||||
'QZSS': '#cc66ff',
|
||||
};
|
||||
|
||||
const CONST_ALTITUDES = {
|
||||
'GPS': 0.28,
|
||||
'GLONASS': 0.27,
|
||||
'Galileo': 0.29,
|
||||
'BeiDou': 0.30,
|
||||
'SBAS': 0.34,
|
||||
'QZSS': 0.31,
|
||||
};
|
||||
|
||||
const GPS_GLOBE_SCRIPT_URLS = [
|
||||
'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js',
|
||||
];
|
||||
const GPS_GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg';
|
||||
const GPS_SATELLITE_ICON_URL = '/static/images/globe/satellite-icon.svg';
|
||||
|
||||
function init() {
|
||||
initSkyRenderer();
|
||||
const initPromise = initSkyRenderer();
|
||||
if (initPromise && typeof initPromise.then === 'function') {
|
||||
initPromise.then(() => {
|
||||
if (lastSky) drawSkyView(lastSky.satellites || []);
|
||||
else drawEmptySkyView();
|
||||
}).catch(() => {});
|
||||
}
|
||||
drawEmptySkyView();
|
||||
if (!connected) connect();
|
||||
|
||||
@@ -48,26 +71,397 @@ const GPS = (function() {
|
||||
}
|
||||
|
||||
function initSkyRenderer() {
|
||||
if (skyRendererInitAttempted) return;
|
||||
if (skyRendererInitPromise) return skyRendererInitPromise;
|
||||
skyRendererInitAttempted = true;
|
||||
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
let fallbackRenderer = null;
|
||||
const fallbackCanvas = document.getElementById('gpsSkyCanvas');
|
||||
const fallbackOverlay = document.getElementById('gpsSkyOverlay');
|
||||
|
||||
const overlay = document.getElementById('gpsSkyOverlay');
|
||||
try {
|
||||
skyRenderer = createWebGlSkyRenderer(canvas, overlay);
|
||||
} catch (err) {
|
||||
skyRenderer = null;
|
||||
console.warn('GPS sky WebGL renderer failed, falling back to 2D', err);
|
||||
// Show an immediate fallback while the globe library loads.
|
||||
setSkyCanvasFallbackMode(true);
|
||||
if (fallbackCanvas) {
|
||||
try {
|
||||
fallbackRenderer = createWebGlSkyRenderer(fallbackCanvas, fallbackOverlay);
|
||||
skyRenderer = fallbackRenderer;
|
||||
} catch (err) {
|
||||
fallbackRenderer = null;
|
||||
skyRenderer = null;
|
||||
console.warn('GPS sky WebGL renderer failed, falling back to 2D', err);
|
||||
}
|
||||
}
|
||||
|
||||
skyRendererInitPromise = (async function() {
|
||||
const globeContainer = document.getElementById('gpsSkyGlobe');
|
||||
if (globeContainer) {
|
||||
try {
|
||||
const globeRenderer = await createGlobeSkyRenderer(globeContainer);
|
||||
if (globeRenderer) {
|
||||
if (fallbackRenderer && fallbackRenderer !== globeRenderer && typeof fallbackRenderer.destroy === 'function') {
|
||||
fallbackRenderer.destroy();
|
||||
}
|
||||
setSkyCanvasFallbackMode(false);
|
||||
skyRenderer = globeRenderer;
|
||||
return skyRenderer;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('GPS globe renderer failed, falling back to canvas renderer', err);
|
||||
}
|
||||
}
|
||||
|
||||
setSkyCanvasFallbackMode(true);
|
||||
if (!fallbackRenderer && fallbackCanvas) {
|
||||
try {
|
||||
fallbackRenderer = createWebGlSkyRenderer(fallbackCanvas, fallbackOverlay);
|
||||
} catch (err) {
|
||||
fallbackRenderer = null;
|
||||
console.warn('GPS sky WebGL renderer failed, falling back to 2D', err);
|
||||
}
|
||||
}
|
||||
|
||||
skyRenderer = fallbackRenderer;
|
||||
return skyRenderer;
|
||||
})();
|
||||
|
||||
return skyRendererInitPromise;
|
||||
}
|
||||
|
||||
function setSkyCanvasFallbackMode(enabled) {
|
||||
const wrap = document.getElementById('gpsSkyViewWrap');
|
||||
if (wrap) {
|
||||
wrap.classList.toggle('gps-sky-fallback', !!enabled);
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
updateConnectionUI(false, false, 'connecting');
|
||||
fetch('/gps/auto-connect', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
|
||||
function isSkyCanvasFallbackEnabled() {
|
||||
const wrap = document.getElementById('gpsSkyViewWrap');
|
||||
return !wrap || wrap.classList.contains('gps-sky-fallback');
|
||||
}
|
||||
|
||||
function getObserverCoords() {
|
||||
const posLat = Number(lastPosition && lastPosition.latitude);
|
||||
const posLon = Number(lastPosition && lastPosition.longitude);
|
||||
if (Number.isFinite(posLat) && Number.isFinite(posLon)) {
|
||||
return { lat: posLat, lon: normalizeLon(posLon) };
|
||||
}
|
||||
|
||||
if (typeof observerLocation === 'object' && observerLocation) {
|
||||
const obsLat = Number(observerLocation.lat);
|
||||
const obsLon = Number(observerLocation.lon);
|
||||
if (Number.isFinite(obsLat) && Number.isFinite(obsLon)) {
|
||||
return { lat: obsLat, lon: normalizeLon(obsLon) };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureGpsGlobeLibrary() {
|
||||
if (typeof window.Globe === 'function') return true;
|
||||
|
||||
const webglSupportFn = (typeof isWebglSupported === 'function') ? isWebglSupported : localWebglSupportCheck;
|
||||
if (!webglSupportFn()) return false;
|
||||
|
||||
if (typeof ensureWebsdrGlobeLibrary === 'function') {
|
||||
try {
|
||||
const ready = await ensureWebsdrGlobeLibrary();
|
||||
if (ready && typeof window.Globe === 'function') return true;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
for (const src of GPS_GLOBE_SCRIPT_URLS) {
|
||||
await loadGpsGlobeScript(src);
|
||||
}
|
||||
return typeof window.Globe === 'function';
|
||||
}
|
||||
|
||||
function loadGpsGlobeScript(src) {
|
||||
const state = getSharedGlobeScriptState();
|
||||
if (!state.promises[src]) {
|
||||
state.promises[src] = loadSharedGlobeScript(src);
|
||||
}
|
||||
return state.promises[src].catch((error) => {
|
||||
delete state.promises[src];
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function getSharedGlobeScriptState() {
|
||||
const key = '__interceptGlobeScriptState';
|
||||
if (!window[key]) {
|
||||
window[key] = {
|
||||
promises: Object.create(null),
|
||||
};
|
||||
}
|
||||
return window[key];
|
||||
}
|
||||
|
||||
function loadSharedGlobeScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const selector = [
|
||||
`script[data-intercept-globe-src="${src}"]`,
|
||||
`script[data-websdr-src="${src}"]`,
|
||||
`script[data-gps-globe-src="${src}"]`,
|
||||
`script[src="${src}"]`,
|
||||
].join(', ');
|
||||
const existing = document.querySelector(selector);
|
||||
|
||||
if (existing) {
|
||||
if (existing.dataset.loaded === 'true') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (existing.dataset.failed === 'true') {
|
||||
existing.remove();
|
||||
} else {
|
||||
existing.addEventListener('load', () => resolve(), { once: true });
|
||||
existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), { once: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.crossOrigin = 'anonymous';
|
||||
script.dataset.interceptGlobeSrc = src;
|
||||
script.dataset.gpsGlobeSrc = src;
|
||||
script.onload = () => {
|
||||
script.dataset.loaded = 'true';
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => {
|
||||
script.dataset.failed = 'true';
|
||||
reject(new Error(`Failed to load ${src}`));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function localWebglSupportCheck() {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'));
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createGlobeSkyRenderer(container) {
|
||||
const ready = await ensureGpsGlobeLibrary();
|
||||
if (!ready || typeof window.Globe !== 'function') return null;
|
||||
|
||||
let layoutAttempts = 0;
|
||||
while ((!container.clientWidth || !container.clientHeight) && layoutAttempts < 4) {
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
layoutAttempts += 1;
|
||||
}
|
||||
if (!container.clientWidth || !container.clientHeight) return null;
|
||||
|
||||
container.innerHTML = '';
|
||||
container.style.background = 'radial-gradient(circle at 32% 18%, rgba(16, 45, 70, 0.92), rgba(4, 9, 16, 0.96) 58%, rgba(2, 4, 9, 0.99) 100%)';
|
||||
container.style.cursor = 'grab';
|
||||
|
||||
const globe = window.Globe()(container)
|
||||
.backgroundColor('rgba(0,0,0,0)')
|
||||
.globeImageUrl(GPS_GLOBE_TEXTURE_URL)
|
||||
.showAtmosphere(true)
|
||||
.atmosphereColor('#3bb9ff')
|
||||
.atmosphereAltitude(0.17)
|
||||
.pointRadius('radius')
|
||||
.pointAltitude('altitude')
|
||||
.pointColor('color')
|
||||
.pointLabel(point => point.label || '')
|
||||
.pointsTransitionDuration(0)
|
||||
.htmlAltitude('altitude')
|
||||
.htmlElementsData([])
|
||||
.htmlElement((sat) => createSatelliteIconElement(sat));
|
||||
|
||||
const controls = globe.controls();
|
||||
if (controls) {
|
||||
controls.autoRotate = false;
|
||||
controls.enablePan = false;
|
||||
controls.minDistance = 130;
|
||||
controls.maxDistance = 420;
|
||||
controls.rotateSpeed = 0.8;
|
||||
controls.zoomSpeed = 0.8;
|
||||
}
|
||||
|
||||
let destroyed = false;
|
||||
let lastSatellites = [];
|
||||
let hasInitialView = false;
|
||||
const resizeObserver = (typeof ResizeObserver !== 'undefined')
|
||||
? new ResizeObserver(() => resizeGlobe())
|
||||
: null;
|
||||
|
||||
if (resizeObserver) resizeObserver.observe(container);
|
||||
|
||||
function resizeGlobe() {
|
||||
if (destroyed) return;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
if (!width || !height) return;
|
||||
globe.width(width);
|
||||
globe.height(height);
|
||||
}
|
||||
|
||||
function renderGlobe() {
|
||||
if (destroyed) return;
|
||||
resizeGlobe();
|
||||
|
||||
const observer = getObserverCoords();
|
||||
const points = [];
|
||||
const satelliteIcons = [];
|
||||
|
||||
if (observer) {
|
||||
points.push({
|
||||
lat: observer.lat,
|
||||
lng: observer.lon,
|
||||
altitude: 0.012,
|
||||
radius: 0.34,
|
||||
color: '#ffffff',
|
||||
label: '<div style="padding:4px 6px; font-size:11px; background:rgba(5,13,20,0.92); border:1px solid rgba(255,255,255,0.28); border-radius:4px;">Observer</div>',
|
||||
});
|
||||
}
|
||||
|
||||
lastSatellites.forEach((sat) => {
|
||||
const azimuth = Number(sat.azimuth);
|
||||
const elevation = Number(sat.elevation);
|
||||
if (!observer || !Number.isFinite(azimuth) || !Number.isFinite(elevation)) return;
|
||||
|
||||
const color = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS;
|
||||
const shellAltitude = getSatelliteShellAltitude(sat.constellation, elevation);
|
||||
const footprint = projectSkyTrackToEarth(observer.lat, observer.lon, azimuth, elevation);
|
||||
satelliteIcons.push({
|
||||
lat: footprint.lat,
|
||||
lng: footprint.lon,
|
||||
altitude: shellAltitude,
|
||||
color: color,
|
||||
used: !!sat.used,
|
||||
sizePx: sat.used ? 20 : 17,
|
||||
title: buildSatelliteTitle(sat),
|
||||
iconUrl: GPS_SATELLITE_ICON_URL,
|
||||
});
|
||||
});
|
||||
|
||||
globe.pointsData(points);
|
||||
globe.htmlElementsData(satelliteIcons);
|
||||
|
||||
if (observer && !hasInitialView) {
|
||||
globe.pointOfView({ lat: observer.lat, lng: observer.lon, altitude: 1.6 }, 950);
|
||||
hasInitialView = true;
|
||||
}
|
||||
}
|
||||
|
||||
function createSatelliteIconElement(sat) {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = `gps-globe-sat-icon ${sat.used ? 'used' : 'unused'}`;
|
||||
marker.style.setProperty('--sat-color', sat.color || '#9fb2c5');
|
||||
marker.style.setProperty('--sat-size', `${Math.max(12, Number(sat.sizePx) || 18)}px`);
|
||||
marker.title = sat.title || 'Satellite';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = sat.iconUrl || GPS_SATELLITE_ICON_URL;
|
||||
img.alt = 'Satellite';
|
||||
img.decoding = 'async';
|
||||
img.draggable = false;
|
||||
|
||||
marker.appendChild(img);
|
||||
return marker;
|
||||
}
|
||||
|
||||
function setSatellites(satellites) {
|
||||
lastSatellites = Array.isArray(satellites) ? satellites : [];
|
||||
renderGlobe();
|
||||
}
|
||||
|
||||
function requestRender() {
|
||||
renderGlobe();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
destroyed = true;
|
||||
if (resizeObserver) {
|
||||
try {
|
||||
resizeObserver.disconnect();
|
||||
} catch (_) {}
|
||||
}
|
||||
container.innerHTML = '';
|
||||
}
|
||||
|
||||
setSatellites([]);
|
||||
|
||||
return {
|
||||
setSatellites: setSatellites,
|
||||
requestRender: requestRender,
|
||||
destroy: destroy,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSatelliteTitle(sat) {
|
||||
const constellation = String(sat.constellation || 'GPS');
|
||||
const prn = String(sat.prn || '--');
|
||||
const elevation = Number.isFinite(Number(sat.elevation)) ? `${Number(sat.elevation).toFixed(1)}\u00b0` : '--';
|
||||
const azimuth = Number.isFinite(Number(sat.azimuth)) ? `${Number(sat.azimuth).toFixed(1)}\u00b0` : '--';
|
||||
const snr = Number.isFinite(Number(sat.snr)) ? `${Math.round(Number(sat.snr))} dB-Hz` : 'n/a';
|
||||
const used = sat.used ? 'USED IN FIX' : 'TRACKED';
|
||||
|
||||
return `${constellation} PRN ${prn} | El ${elevation} | Az ${azimuth} | SNR ${snr} | ${used}`;
|
||||
}
|
||||
|
||||
function getSatelliteShellAltitude(constellation, elevation) {
|
||||
const base = CONST_ALTITUDES[constellation] || CONST_ALTITUDES.GPS;
|
||||
const el = Math.max(0, Math.min(90, Number(elevation) || 0));
|
||||
const horizonFactor = 1 - (el / 90);
|
||||
return base + (horizonFactor * 0.04);
|
||||
}
|
||||
|
||||
function projectSkyTrackToEarth(observerLat, observerLon, azimuth, elevation) {
|
||||
const el = Math.max(0, Math.min(90, Number(elevation) || 0));
|
||||
const horizonFactor = 1 - (el / 90);
|
||||
const angularDistance = 76 * Math.pow(horizonFactor, 1.08);
|
||||
return destinationPoint(observerLat, observerLon, azimuth, angularDistance);
|
||||
}
|
||||
|
||||
function destinationPoint(latDeg, lonDeg, bearingDeg, distanceDeg) {
|
||||
const lat1 = degToRad(latDeg);
|
||||
const lon1 = degToRad(lonDeg);
|
||||
const bearing = degToRad(bearingDeg);
|
||||
const distance = degToRad(distanceDeg);
|
||||
|
||||
const sinLat1 = Math.sin(lat1);
|
||||
const cosLat1 = Math.cos(lat1);
|
||||
const sinDist = Math.sin(distance);
|
||||
const cosDist = Math.cos(distance);
|
||||
|
||||
const sinLat2 = (sinLat1 * cosDist) + (cosLat1 * sinDist * Math.cos(bearing));
|
||||
const lat2 = Math.asin(Math.max(-1, Math.min(1, sinLat2)));
|
||||
|
||||
const y = Math.sin(bearing) * sinDist * cosLat1;
|
||||
const x = cosDist - (sinLat1 * Math.sin(lat2));
|
||||
const lon2 = lon1 + Math.atan2(y, x);
|
||||
|
||||
return {
|
||||
lat: radToDeg(lat2),
|
||||
lon: normalizeLon(radToDeg(lon2)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLon(lon) {
|
||||
let normalized = (lon + 540) % 360;
|
||||
normalized = normalized < 0 ? normalized + 360 : normalized;
|
||||
return normalized - 180;
|
||||
}
|
||||
|
||||
function radToDeg(rad) {
|
||||
return rad * 180 / Math.PI;
|
||||
}
|
||||
|
||||
function connect() {
|
||||
updateConnectionUI(false, false, 'connecting');
|
||||
fetch('/gps/auto-connect', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'connected') {
|
||||
connected = true;
|
||||
updateConnectionUI(true, data.has_fix);
|
||||
@@ -78,16 +472,18 @@ const GPS = (function() {
|
||||
if (data.sky) {
|
||||
lastSky = data.sky;
|
||||
updateSkyUI(data.sky);
|
||||
}
|
||||
subscribeToStream();
|
||||
startSkyPolling();
|
||||
// Ensure the global GPS stream is running
|
||||
if (typeof startGpsStream === 'function' && !gpsEventSource) {
|
||||
startGpsStream();
|
||||
}
|
||||
} else {
|
||||
connected = false;
|
||||
updateConnectionUI(false, false, 'error', data.message || 'gpsd not available');
|
||||
}
|
||||
subscribeToStream();
|
||||
startSkyPolling();
|
||||
startStatusPolling();
|
||||
// Ensure the global GPS stream is running
|
||||
const hasGlobalGpsStream = typeof gpsEventSource !== 'undefined' && !!gpsEventSource;
|
||||
if (typeof startGpsStream === 'function' && !hasGlobalGpsStream) {
|
||||
startGpsStream();
|
||||
}
|
||||
} else {
|
||||
connected = false;
|
||||
updateConnectionUI(false, false, 'error', data.message || 'gpsd not available');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -96,36 +492,40 @@ const GPS = (function() {
|
||||
});
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
unsubscribeFromStream();
|
||||
stopSkyPolling();
|
||||
fetch('/gps/stop', { method: 'POST' })
|
||||
.then(() => {
|
||||
connected = false;
|
||||
updateConnectionUI(false);
|
||||
});
|
||||
function disconnect() {
|
||||
unsubscribeFromStream();
|
||||
stopSkyPolling();
|
||||
stopStatusPolling();
|
||||
fetch('/gps/stop', { method: 'POST' })
|
||||
.then(() => {
|
||||
connected = false;
|
||||
updateConnectionUI(false);
|
||||
});
|
||||
}
|
||||
|
||||
function onGpsStreamData(data) {
|
||||
if (!connected) return;
|
||||
if (data.type === 'position') {
|
||||
lastPosition = data;
|
||||
updatePositionUI(data);
|
||||
updateConnectionUI(true, true);
|
||||
} else if (data.type === 'sky') {
|
||||
lastSky = data;
|
||||
updateSkyUI(data);
|
||||
}
|
||||
}
|
||||
|
||||
function startSkyPolling() {
|
||||
stopSkyPolling();
|
||||
// Poll satellite data every 5 seconds as a reliable fallback
|
||||
// SSE stream may miss sky updates due to queue contention with position messages
|
||||
pollSatellites();
|
||||
skyPollTimer = setInterval(pollSatellites, 5000);
|
||||
}
|
||||
if (data.type === 'position') {
|
||||
lastPosition = data;
|
||||
updatePositionUI(data);
|
||||
updateConnectionUI(true, true);
|
||||
if (lastSky && skyRenderer) {
|
||||
drawSkyView(lastSky.satellites || []);
|
||||
}
|
||||
} else if (data.type === 'sky') {
|
||||
lastSky = data;
|
||||
updateSkyUI(data);
|
||||
}
|
||||
}
|
||||
|
||||
function startSkyPolling() {
|
||||
stopSkyPolling();
|
||||
// Poll satellite data every 5 seconds as a reliable fallback
|
||||
// SSE stream may miss sky updates due to queue contention with position messages
|
||||
pollSatellites();
|
||||
skyPollTimer = setInterval(pollSatellites, 5000);
|
||||
}
|
||||
|
||||
function stopSkyPolling() {
|
||||
if (skyPollTimer) {
|
||||
clearInterval(skyPollTimer);
|
||||
@@ -133,18 +533,62 @@ const GPS = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
function pollSatellites() {
|
||||
if (!connected) return;
|
||||
fetch('/gps/satellites')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
function pollSatellites() {
|
||||
if (!connected) return;
|
||||
fetch('/gps/satellites')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'ok' && data.sky) {
|
||||
lastSky = data.sky;
|
||||
updateSkyUI(data.sky);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function startStatusPolling() {
|
||||
stopStatusPolling();
|
||||
// Poll full status as a fallback when SSE is unavailable or blocked.
|
||||
pollStatus();
|
||||
statusPollTimer = setInterval(pollStatus, 2000);
|
||||
}
|
||||
|
||||
function stopStatusPolling() {
|
||||
if (statusPollTimer) {
|
||||
clearInterval(statusPollTimer);
|
||||
statusPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function pollStatus() {
|
||||
if (!connected) return;
|
||||
fetch('/gps/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!connected || !data) return;
|
||||
if (data.running !== true) {
|
||||
connected = false;
|
||||
stopSkyPolling();
|
||||
stopStatusPolling();
|
||||
updateConnectionUI(false, false, 'error', data.message || 'GPS disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.position) {
|
||||
lastPosition = data.position;
|
||||
updatePositionUI(data.position);
|
||||
updateConnectionUI(true, true);
|
||||
} else {
|
||||
updateConnectionUI(true, false);
|
||||
}
|
||||
|
||||
if (data.sky) {
|
||||
lastSky = data.sky;
|
||||
updateSkyUI(data.sky);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function subscribeToStream() {
|
||||
// Subscribe to the global GPS stream instead of opening a separate SSE connection
|
||||
@@ -294,8 +738,11 @@ const GPS = (function() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSkyCanvasFallbackEnabled()) return;
|
||||
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
resize2DFallbackCanvas(canvas);
|
||||
drawSkyViewBase2D(canvas);
|
||||
}
|
||||
|
||||
@@ -311,9 +758,12 @@ const GPS = (function() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSkyCanvasFallbackEnabled()) return;
|
||||
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
|
||||
resize2DFallbackCanvas(canvas);
|
||||
drawSkyViewBase2D(canvas);
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -428,6 +878,15 @@ const GPS = (function() {
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function resize2DFallbackCanvas(canvas) {
|
||||
const cssWidth = Math.max(1, Math.floor(canvas.clientWidth || 400));
|
||||
const cssHeight = Math.max(1, Math.floor(canvas.clientHeight || 400));
|
||||
if (canvas.width !== cssWidth || canvas.height !== cssHeight) {
|
||||
canvas.width = cssWidth;
|
||||
canvas.height = cssHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function createWebGlSkyRenderer(canvas, overlay) {
|
||||
const gl = canvas.getContext('webgl', { antialias: true, alpha: false, depth: true });
|
||||
if (!gl) return null;
|
||||
@@ -1076,6 +1535,7 @@ const GPS = (function() {
|
||||
function destroy() {
|
||||
unsubscribeFromStream();
|
||||
stopSkyPolling();
|
||||
stopStatusPolling();
|
||||
if (themeObserver) {
|
||||
themeObserver.disconnect();
|
||||
themeObserver = null;
|
||||
@@ -1085,6 +1545,8 @@ const GPS = (function() {
|
||||
skyRenderer = null;
|
||||
}
|
||||
skyRendererInitAttempted = false;
|
||||
skyRendererInitPromise = null;
|
||||
setSkyCanvasFallbackMode(false);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Morse Code (CW) decoder module.
|
||||
*
|
||||
* IIFE providing start/stop controls, SSE streaming, scope canvas,
|
||||
* decoded text display, and export capabilities.
|
||||
*/
|
||||
var MorseMode = (function () {
|
||||
'use strict';
|
||||
|
||||
var state = {
|
||||
running: false,
|
||||
initialized: false,
|
||||
eventSource: null,
|
||||
charCount: 0,
|
||||
decodedLog: [], // { timestamp, morse, char }
|
||||
};
|
||||
|
||||
// Scope state
|
||||
var scopeCtx = null;
|
||||
var scopeAnim = null;
|
||||
var scopeHistory = [];
|
||||
var SCOPE_HISTORY_LEN = 300;
|
||||
var scopeThreshold = 0;
|
||||
var scopeToneOn = false;
|
||||
|
||||
// ---- Initialization ----
|
||||
|
||||
function init() {
|
||||
if (state.initialized) {
|
||||
checkStatus();
|
||||
return;
|
||||
}
|
||||
state.initialized = true;
|
||||
checkStatus();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
disconnectSSE();
|
||||
stopScope();
|
||||
}
|
||||
|
||||
// ---- Status ----
|
||||
|
||||
function checkStatus() {
|
||||
fetch('/morse/status')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.running) {
|
||||
state.running = true;
|
||||
updateUI(true);
|
||||
connectSSE();
|
||||
startScope();
|
||||
} else {
|
||||
state.running = false;
|
||||
updateUI(false);
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
// ---- Start / Stop ----
|
||||
|
||||
function start() {
|
||||
if (state.running) return;
|
||||
|
||||
var payload = {
|
||||
frequency: document.getElementById('morseFrequency').value || '14.060',
|
||||
gain: document.getElementById('morseGain').value || '0',
|
||||
ppm: document.getElementById('morsePPM').value || '0',
|
||||
device: document.getElementById('deviceSelect')?.value || '0',
|
||||
sdr_type: document.getElementById('sdrTypeSelect')?.value || 'rtlsdr',
|
||||
tone_freq: document.getElementById('morseToneFreq').value || '700',
|
||||
wpm: document.getElementById('morseWpm').value || '15',
|
||||
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
|
||||
};
|
||||
|
||||
fetch('/morse/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.status === 'started') {
|
||||
state.running = true;
|
||||
state.charCount = 0;
|
||||
state.decodedLog = [];
|
||||
updateUI(true);
|
||||
connectSSE();
|
||||
startScope();
|
||||
clearDecodedText();
|
||||
} else {
|
||||
alert('Error: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
alert('Failed to start Morse decoder: ' + err);
|
||||
});
|
||||
}
|
||||
|
||||
function stop() {
|
||||
fetch('/morse/stop', { method: 'POST' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function () {
|
||||
state.running = false;
|
||||
updateUI(false);
|
||||
disconnectSSE();
|
||||
stopScope();
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
// ---- SSE ----
|
||||
|
||||
function connectSSE() {
|
||||
disconnectSSE();
|
||||
var es = new EventSource('/morse/stream');
|
||||
|
||||
es.onmessage = function (e) {
|
||||
try {
|
||||
var msg = JSON.parse(e.data);
|
||||
handleMessage(msg);
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
es.onerror = function () {
|
||||
// Reconnect handled by browser
|
||||
};
|
||||
|
||||
state.eventSource = es;
|
||||
}
|
||||
|
||||
function disconnectSSE() {
|
||||
if (state.eventSource) {
|
||||
state.eventSource.close();
|
||||
state.eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(msg) {
|
||||
var type = msg.type;
|
||||
|
||||
if (type === 'scope') {
|
||||
// Update scope data
|
||||
var amps = msg.amplitudes || [];
|
||||
for (var i = 0; i < amps.length; i++) {
|
||||
scopeHistory.push(amps[i]);
|
||||
if (scopeHistory.length > SCOPE_HISTORY_LEN) {
|
||||
scopeHistory.shift();
|
||||
}
|
||||
}
|
||||
scopeThreshold = msg.threshold || 0;
|
||||
scopeToneOn = msg.tone_on || false;
|
||||
|
||||
} else if (type === 'morse_char') {
|
||||
appendChar(msg.char, msg.morse, msg.timestamp);
|
||||
|
||||
} else if (type === 'morse_space') {
|
||||
appendSpace();
|
||||
|
||||
} else if (type === 'status') {
|
||||
if (msg.status === 'stopped') {
|
||||
state.running = false;
|
||||
updateUI(false);
|
||||
disconnectSSE();
|
||||
stopScope();
|
||||
}
|
||||
} else if (type === 'error') {
|
||||
console.error('Morse error:', msg.text);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Decoded text ----
|
||||
|
||||
function appendChar(ch, morse, timestamp) {
|
||||
state.charCount++;
|
||||
state.decodedLog.push({ timestamp: timestamp, morse: morse, char: ch });
|
||||
|
||||
var panel = document.getElementById('morseDecodedText');
|
||||
if (!panel) return;
|
||||
|
||||
var span = document.createElement('span');
|
||||
span.className = 'morse-char';
|
||||
span.textContent = ch;
|
||||
span.title = morse + ' (' + timestamp + ')';
|
||||
panel.appendChild(span);
|
||||
|
||||
// Auto-scroll
|
||||
panel.scrollTop = panel.scrollHeight;
|
||||
|
||||
// Update count
|
||||
var countEl = document.getElementById('morseCharCount');
|
||||
if (countEl) countEl.textContent = state.charCount + ' chars';
|
||||
var barChars = document.getElementById('morseStatusBarChars');
|
||||
if (barChars) barChars.textContent = state.charCount + ' chars decoded';
|
||||
}
|
||||
|
||||
function appendSpace() {
|
||||
var panel = document.getElementById('morseDecodedText');
|
||||
if (!panel) return;
|
||||
|
||||
var span = document.createElement('span');
|
||||
span.className = 'morse-word-space';
|
||||
span.textContent = ' ';
|
||||
panel.appendChild(span);
|
||||
}
|
||||
|
||||
function clearDecodedText() {
|
||||
var panel = document.getElementById('morseDecodedText');
|
||||
if (panel) panel.innerHTML = '';
|
||||
state.charCount = 0;
|
||||
state.decodedLog = [];
|
||||
var countEl = document.getElementById('morseCharCount');
|
||||
if (countEl) countEl.textContent = '0 chars';
|
||||
var barChars = document.getElementById('morseStatusBarChars');
|
||||
if (barChars) barChars.textContent = '0 chars decoded';
|
||||
}
|
||||
|
||||
// ---- Scope canvas ----
|
||||
|
||||
function startScope() {
|
||||
var canvas = document.getElementById('morseScopeCanvas');
|
||||
if (!canvas) return;
|
||||
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = 80 * dpr;
|
||||
canvas.style.height = '80px';
|
||||
|
||||
scopeCtx = canvas.getContext('2d');
|
||||
scopeCtx.scale(dpr, dpr);
|
||||
scopeHistory = [];
|
||||
|
||||
var toneLabel = document.getElementById('morseScopeToneLabel');
|
||||
var threshLabel = document.getElementById('morseScopeThreshLabel');
|
||||
|
||||
function draw() {
|
||||
if (!scopeCtx) return;
|
||||
var w = rect.width;
|
||||
var h = 80;
|
||||
|
||||
scopeCtx.fillStyle = '#050510';
|
||||
scopeCtx.fillRect(0, 0, w, h);
|
||||
|
||||
// Update header labels
|
||||
if (toneLabel) toneLabel.textContent = scopeToneOn ? 'ON' : '--';
|
||||
if (threshLabel) threshLabel.textContent = scopeThreshold > 0 ? Math.round(scopeThreshold) : '--';
|
||||
|
||||
if (scopeHistory.length === 0) {
|
||||
scopeAnim = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find max for normalization
|
||||
var maxVal = 0;
|
||||
for (var i = 0; i < scopeHistory.length; i++) {
|
||||
if (scopeHistory[i] > maxVal) maxVal = scopeHistory[i];
|
||||
}
|
||||
if (maxVal === 0) maxVal = 1;
|
||||
|
||||
var barW = w / SCOPE_HISTORY_LEN;
|
||||
var threshNorm = scopeThreshold / maxVal;
|
||||
|
||||
// Draw amplitude bars
|
||||
for (var j = 0; j < scopeHistory.length; j++) {
|
||||
var norm = scopeHistory[j] / maxVal;
|
||||
var barH = norm * (h - 10);
|
||||
var x = j * barW;
|
||||
var y = h - barH;
|
||||
|
||||
// Green if above threshold, gray if below
|
||||
if (scopeHistory[j] > scopeThreshold) {
|
||||
scopeCtx.fillStyle = '#00ff88';
|
||||
} else {
|
||||
scopeCtx.fillStyle = '#334455';
|
||||
}
|
||||
scopeCtx.fillRect(x, y, Math.max(barW - 1, 1), barH);
|
||||
}
|
||||
|
||||
// Draw threshold line
|
||||
if (scopeThreshold > 0) {
|
||||
var threshY = h - (threshNorm * (h - 10));
|
||||
scopeCtx.strokeStyle = '#ff4444';
|
||||
scopeCtx.lineWidth = 1;
|
||||
scopeCtx.setLineDash([4, 4]);
|
||||
scopeCtx.beginPath();
|
||||
scopeCtx.moveTo(0, threshY);
|
||||
scopeCtx.lineTo(w, threshY);
|
||||
scopeCtx.stroke();
|
||||
scopeCtx.setLineDash([]);
|
||||
}
|
||||
|
||||
// Tone indicator
|
||||
if (scopeToneOn) {
|
||||
scopeCtx.fillStyle = '#00ff88';
|
||||
scopeCtx.beginPath();
|
||||
scopeCtx.arc(w - 12, 12, 5, 0, Math.PI * 2);
|
||||
scopeCtx.fill();
|
||||
}
|
||||
|
||||
scopeAnim = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
draw();
|
||||
}
|
||||
|
||||
function stopScope() {
|
||||
if (scopeAnim) {
|
||||
cancelAnimationFrame(scopeAnim);
|
||||
scopeAnim = null;
|
||||
}
|
||||
scopeCtx = null;
|
||||
}
|
||||
|
||||
// ---- Export ----
|
||||
|
||||
function exportTxt() {
|
||||
var text = state.decodedLog.map(function (e) { return e.char; }).join('');
|
||||
downloadFile('morse_decoded.txt', text, 'text/plain');
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
var lines = ['timestamp,morse,character'];
|
||||
state.decodedLog.forEach(function (e) {
|
||||
lines.push(e.timestamp + ',"' + e.morse + '",' + e.char);
|
||||
});
|
||||
downloadFile('morse_decoded.csv', lines.join('\n'), 'text/csv');
|
||||
}
|
||||
|
||||
function copyToClipboard() {
|
||||
var text = state.decodedLog.map(function (e) { return e.char; }).join('');
|
||||
navigator.clipboard.writeText(text).then(function () {
|
||||
var btn = document.getElementById('morseCopyBtn');
|
||||
if (btn) {
|
||||
var orig = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(function () { btn.textContent = orig; }, 1500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function downloadFile(filename, content, type) {
|
||||
var blob = new Blob([content], { type: type });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ---- UI ----
|
||||
|
||||
function updateUI(running) {
|
||||
var startBtn = document.getElementById('morseStartBtn');
|
||||
var stopBtn = document.getElementById('morseStopBtn');
|
||||
var indicator = document.getElementById('morseStatusIndicator');
|
||||
var statusText = document.getElementById('morseStatusText');
|
||||
|
||||
if (startBtn) startBtn.style.display = running ? 'none' : '';
|
||||
if (stopBtn) stopBtn.style.display = running ? '' : 'none';
|
||||
|
||||
if (indicator) {
|
||||
indicator.style.background = running ? '#00ff88' : 'var(--text-dim)';
|
||||
}
|
||||
if (statusText) {
|
||||
statusText.textContent = running ? 'Listening' : 'Standby';
|
||||
}
|
||||
|
||||
// Toggle scope and output panels (pager/sensor pattern)
|
||||
var scopePanel = document.getElementById('morseScopePanel');
|
||||
var outputPanel = document.getElementById('morseOutputPanel');
|
||||
if (scopePanel) scopePanel.style.display = running ? 'block' : 'none';
|
||||
if (outputPanel) outputPanel.style.display = running ? 'block' : 'none';
|
||||
|
||||
var scopeStatus = document.getElementById('morseScopeStatusLabel');
|
||||
if (scopeStatus) scopeStatus.textContent = running ? 'ACTIVE' : 'IDLE';
|
||||
if (scopeStatus) scopeStatus.style.color = running ? '#0f0' : '#444';
|
||||
}
|
||||
|
||||
function setFreq(mhz) {
|
||||
var el = document.getElementById('morseFrequency');
|
||||
if (el) el.value = mhz;
|
||||
}
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
return {
|
||||
init: init,
|
||||
destroy: destroy,
|
||||
start: start,
|
||||
stop: stop,
|
||||
setFreq: setFreq,
|
||||
exportTxt: exportTxt,
|
||||
exportCsv: exportCsv,
|
||||
copyToClipboard: copyToClipboard,
|
||||
clearText: clearDecodedText,
|
||||
};
|
||||
})();
|
||||
+105
-65
@@ -36,6 +36,7 @@ const Waterfall = (function () {
|
||||
|
||||
let _startMhz = 98.8;
|
||||
let _endMhz = 101.2;
|
||||
let _lastEffectiveSpan = 2.4;
|
||||
let _monitorFreqMhz = 100.0;
|
||||
|
||||
let _monitoring = false;
|
||||
@@ -2515,6 +2516,11 @@ const Waterfall = (function () {
|
||||
_endMhz = msg.end_freq;
|
||||
_drawFreqAxis();
|
||||
}
|
||||
if (Number.isFinite(msg.effective_span_mhz)) {
|
||||
_lastEffectiveSpan = msg.effective_span_mhz;
|
||||
const spanEl = document.getElementById('wfSpanMhz');
|
||||
if (spanEl) spanEl.value = msg.effective_span_mhz;
|
||||
}
|
||||
_setStatus(`Streaming ${_startMhz.toFixed(4)} - ${_endMhz.toFixed(4)} MHz`);
|
||||
_setVisualStatus('RUNNING');
|
||||
if (_monitoring) {
|
||||
@@ -2535,6 +2541,12 @@ const Waterfall = (function () {
|
||||
}
|
||||
_updateFreqDisplay();
|
||||
_setStatus(`Tuned ${_monitorFreqMhz.toFixed(4)} MHz`);
|
||||
if (_monitoring && _monitorSource === 'waterfall') {
|
||||
const mode = _getMonitorMode().toUpperCase();
|
||||
_setMonitorState(`Monitoring ${_monitorFreqMhz.toFixed(4)} MHz ${mode} via shared IQ`);
|
||||
_setStatus(`Audio monitor active on ${_monitorFreqMhz.toFixed(4)} MHz (${mode})`);
|
||||
_setVisualStatus('MONITOR');
|
||||
}
|
||||
if (!_monitoring) _setVisualStatus('RUNNING');
|
||||
} else if (_onRetuneRequired(msg)) {
|
||||
return;
|
||||
@@ -2557,6 +2569,10 @@ const Waterfall = (function () {
|
||||
_pendingMonitorTuneMhz = null;
|
||||
_scanStartPending = false;
|
||||
_pendingSharedMonitorRearm = false;
|
||||
// Reset span input to last known good value so an
|
||||
// invalid span doesn't persist across restart (#150).
|
||||
const spanEl = document.getElementById('wfSpanMhz');
|
||||
if (spanEl) spanEl.value = _lastEffectiveSpan;
|
||||
// If the monitor was using the shared IQ stream that
|
||||
// just failed, tear down the stale monitor state so
|
||||
// the button becomes clickable again after restart.
|
||||
@@ -2603,7 +2619,7 @@ const Waterfall = (function () {
|
||||
player.load();
|
||||
}
|
||||
|
||||
async function _attachMonitorAudio(nonce) {
|
||||
async function _attachMonitorAudio(nonce, streamToken = null) {
|
||||
const player = document.getElementById('wfAudioPlayer');
|
||||
if (!player) {
|
||||
return { ok: false, reason: 'player_missing', message: 'Audio player is unavailable.' };
|
||||
@@ -2622,7 +2638,10 @@ const Waterfall = (function () {
|
||||
}
|
||||
|
||||
await _pauseMonitorAudioElement();
|
||||
player.src = `/receiver/audio/stream?fresh=1&t=${Date.now()}-${attempt}`;
|
||||
const tokenQuery = (streamToken !== null && streamToken !== undefined && String(streamToken).length > 0)
|
||||
? `&request_token=${encodeURIComponent(String(streamToken))}`
|
||||
: '';
|
||||
player.src = `/receiver/audio/stream?fresh=1&t=${Date.now()}-${attempt}${tokenQuery}`;
|
||||
player.load();
|
||||
|
||||
try {
|
||||
@@ -2678,25 +2697,6 @@ const Waterfall = (function () {
|
||||
};
|
||||
}
|
||||
|
||||
function _deviceKey(device) {
|
||||
if (!device) return '';
|
||||
return `${device.sdrType || ''}:${device.deviceIndex || 0}`;
|
||||
}
|
||||
|
||||
function _findAlternateDevice(currentDevice) {
|
||||
const currentKey = _deviceKey(currentDevice);
|
||||
for (const d of _devices) {
|
||||
const candidate = {
|
||||
sdrType: String(d.sdr_type || 'rtlsdr'),
|
||||
deviceIndex: parseInt(d.index, 10) || 0,
|
||||
};
|
||||
if (_deviceKey(candidate) !== currentKey) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function _requestAudioStart({
|
||||
frequency,
|
||||
modulation,
|
||||
@@ -2760,6 +2760,7 @@ const Waterfall = (function () {
|
||||
_resumeWaterfallAfterMonitor = !!wasRunningWaterfall;
|
||||
}
|
||||
|
||||
const liveCenterMhz = _currentCenter();
|
||||
// Keep an explicit pending tune target so retunes cannot fall
|
||||
// back to a stale frequency during capture restart churn.
|
||||
const requestedTuneMhz = Number.isFinite(_pendingMonitorTuneMhz)
|
||||
@@ -2767,11 +2768,11 @@ const Waterfall = (function () {
|
||||
: (
|
||||
Number.isFinite(_pendingCaptureVfoMhz)
|
||||
? _pendingCaptureVfoMhz
|
||||
: (Number.isFinite(_monitorFreqMhz) ? _monitorFreqMhz : _currentCenter())
|
||||
: (Number.isFinite(_monitorFreqMhz) ? _monitorFreqMhz : liveCenterMhz)
|
||||
);
|
||||
const centerMhz = retuneOnly
|
||||
? (Number.isFinite(requestedTuneMhz) ? requestedTuneMhz : _currentCenter())
|
||||
: _currentCenter();
|
||||
? (Number.isFinite(liveCenterMhz) ? liveCenterMhz : requestedTuneMhz)
|
||||
: liveCenterMhz;
|
||||
const mode = document.getElementById('wfMonitorMode')?.value || 'wfm';
|
||||
const squelch = parseInt(document.getElementById('wfMonitorSquelch')?.value, 10) || 0;
|
||||
const sliderGain = parseInt(document.getElementById('wfMonitorGain')?.value, 10);
|
||||
@@ -2780,69 +2781,98 @@ const Waterfall = (function () {
|
||||
? sliderGain
|
||||
: (Number.isFinite(fallbackGain) ? Math.round(fallbackGain) : 40);
|
||||
const selectedDevice = _selectedDevice();
|
||||
const altDevice = _running ? _findAlternateDevice(selectedDevice) : null;
|
||||
let monitorDevice = altDevice || selectedDevice;
|
||||
// Always target the currently selected SDR for monitor start/retune.
|
||||
// This keeps waterfall-shared monitor tuning deterministic and avoids
|
||||
// retuning a different receiver than the one driving the display.
|
||||
let monitorDevice = selectedDevice;
|
||||
const biasT = !!document.getElementById('wfBiasT')?.checked;
|
||||
const usingSecondaryDevice = !!altDevice;
|
||||
// Use a high monotonic token so backend start ordering remains
|
||||
// valid across page reloads (local nonces reset to small values).
|
||||
const requestToken = Math.trunc((Date.now() * 4096) + (nonce & 0x0fff));
|
||||
|
||||
if (!retuneOnly) {
|
||||
_monitorFreqMhz = centerMhz;
|
||||
} else if (Number.isFinite(centerMhz)) {
|
||||
_monitorFreqMhz = centerMhz;
|
||||
_pendingMonitorTuneMhz = centerMhz;
|
||||
_pendingCaptureVfoMhz = centerMhz;
|
||||
}
|
||||
_drawFreqAxis();
|
||||
_stopSmeter();
|
||||
_setUnlockVisible(false);
|
||||
_audioUnlockRequired = false;
|
||||
|
||||
if (usingSecondaryDevice) {
|
||||
if (retuneOnly && _monitoring) {
|
||||
_setMonitorState(`Retuning ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()}...`);
|
||||
} else {
|
||||
_setMonitorState(
|
||||
`Starting ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} on `
|
||||
+ `${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}...`
|
||||
);
|
||||
} else {
|
||||
_setMonitorState(`Starting ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()}...`);
|
||||
}
|
||||
|
||||
// Use live _monitorFreqMhz for retunes so that any user
|
||||
// clicks that changed the VFO during the async setup are
|
||||
// picked up rather than overridden.
|
||||
let { response, payload } = await _requestAudioStart({
|
||||
frequency: centerMhz,
|
||||
modulation: mode,
|
||||
squelch,
|
||||
gain,
|
||||
device: monitorDevice,
|
||||
biasT,
|
||||
requestToken: nonce,
|
||||
});
|
||||
const requestAudioStartResynced = async (deviceForRequest) => {
|
||||
let startResult = await _requestAudioStart({
|
||||
frequency: centerMhz,
|
||||
modulation: mode,
|
||||
squelch,
|
||||
gain,
|
||||
device: deviceForRequest,
|
||||
biasT,
|
||||
requestToken,
|
||||
});
|
||||
const startPayload = startResult?.payload || {};
|
||||
const isStale = startPayload.superseded === true || startPayload.status === 'stale';
|
||||
if (isStale) {
|
||||
const currentToken = Number(startPayload.current_token);
|
||||
if (Number.isFinite(currentToken) && currentToken >= 0) {
|
||||
startResult = await _requestAudioStart({
|
||||
frequency: centerMhz,
|
||||
modulation: mode,
|
||||
squelch,
|
||||
gain,
|
||||
device: deviceForRequest,
|
||||
biasT,
|
||||
requestToken: currentToken + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return startResult;
|
||||
};
|
||||
|
||||
let { response, payload } = await requestAudioStartResynced(monitorDevice);
|
||||
if (nonce !== _audioConnectNonce) return;
|
||||
|
||||
const staleStart = payload?.superseded === true || payload?.status === 'stale';
|
||||
if (staleStart) return;
|
||||
if (staleStart) {
|
||||
// If the backend still reports stale after token resync,
|
||||
// schedule a fresh retune so monitor audio does not stay on
|
||||
// an older station indefinitely.
|
||||
if (_monitoring) {
|
||||
const liveMode = _getMonitorMode().toUpperCase();
|
||||
_setMonitorState(`Monitoring ${_monitorFreqMhz.toFixed(4)} MHz ${liveMode}`);
|
||||
_setStatus(`Audio monitor active on ${_monitorFreqMhz.toFixed(4)} MHz (${liveMode})`);
|
||||
_setVisualStatus('MONITOR');
|
||||
_queueMonitorRetune(90);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const busy = payload?.error_type === 'DEVICE_BUSY' || (response.status === 409 && !staleStart);
|
||||
if (
|
||||
busy
|
||||
&& _running
|
||||
&& !usingSecondaryDevice
|
||||
&& !retuneOnly
|
||||
) {
|
||||
if (busy && _running && !retuneOnly) {
|
||||
_setMonitorState('Audio device busy, pausing waterfall and retrying monitor...');
|
||||
await stop({ keepStatus: true });
|
||||
_resumeWaterfallAfterMonitor = true;
|
||||
await _wait(220);
|
||||
monitorDevice = selectedDevice;
|
||||
({ response, payload } = await _requestAudioStart({
|
||||
frequency: centerMhz,
|
||||
modulation: mode,
|
||||
squelch,
|
||||
gain,
|
||||
device: monitorDevice,
|
||||
biasT,
|
||||
requestToken: nonce,
|
||||
}));
|
||||
({ response, payload } = await requestAudioStartResynced(monitorDevice));
|
||||
if (nonce !== _audioConnectNonce) return;
|
||||
if (payload?.superseded === true || payload?.status === 'stale') return;
|
||||
if (payload?.superseded === true || payload?.status === 'stale') {
|
||||
if (_monitoring) _queueMonitorRetune(90);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok || payload.status !== 'started') {
|
||||
@@ -2861,13 +2891,14 @@ const Waterfall = (function () {
|
||||
return;
|
||||
}
|
||||
|
||||
const attach = await _attachMonitorAudio(nonce);
|
||||
const attach = await _attachMonitorAudio(nonce, payload?.request_token);
|
||||
if (nonce !== _audioConnectNonce) return;
|
||||
_monitorSource = payload?.source === 'waterfall' ? 'waterfall' : 'process';
|
||||
if (
|
||||
const pendingTuneMismatch = (
|
||||
Number.isFinite(_pendingMonitorTuneMhz)
|
||||
&& Math.abs(_pendingMonitorTuneMhz - centerMhz) < 1e-6
|
||||
) {
|
||||
&& Math.abs(_pendingMonitorTuneMhz - centerMhz) >= 1e-6
|
||||
);
|
||||
if (!pendingTuneMismatch) {
|
||||
_pendingMonitorTuneMhz = null;
|
||||
}
|
||||
|
||||
@@ -2878,6 +2909,7 @@ const Waterfall = (function () {
|
||||
_setMonitorState(`Monitoring ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} (audio locked)`);
|
||||
_setStatus('Monitor started but browser blocked playback. Click Unlock Audio.');
|
||||
_setVisualStatus('MONITOR');
|
||||
if (pendingTuneMismatch) _queueMonitorRetune(45);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2911,20 +2943,27 @@ const Waterfall = (function () {
|
||||
_setMonitorState(
|
||||
`Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()} via shared IQ`
|
||||
);
|
||||
} else if (usingSecondaryDevice) {
|
||||
} else {
|
||||
_setMonitorState(
|
||||
`Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()} `
|
||||
+ `via ${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}`
|
||||
);
|
||||
} else {
|
||||
_setMonitorState(`Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()}`);
|
||||
}
|
||||
_setStatus(`Audio monitor active on ${displayMhz.toFixed(4)} MHz (${mode.toUpperCase()})`);
|
||||
_setVisualStatus('MONITOR');
|
||||
if (pendingTuneMismatch) {
|
||||
_queueMonitorRetune(45);
|
||||
}
|
||||
// After a retune reconnect, sync the backend to the latest
|
||||
// VFO in case the user clicked a new frequency while the
|
||||
// audio stream was reconnecting.
|
||||
if (retuneOnly && _monitorSource === 'waterfall' && _ws && _ws.readyState === WebSocket.OPEN) {
|
||||
if (
|
||||
!pendingTuneMismatch
|
||||
&& retuneOnly
|
||||
&& _monitorSource === 'waterfall'
|
||||
&& _ws
|
||||
&& _ws.readyState === WebSocket.OPEN
|
||||
) {
|
||||
_sendWsTuneCmd();
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -3233,7 +3272,8 @@ const Waterfall = (function () {
|
||||
|
||||
function stepFreq(multiplier) {
|
||||
const step = _getNumber('wfStepSize', 0.1);
|
||||
_setAndTune(_currentCenter() + multiplier * step, true);
|
||||
// Coalesce rapid step-button presses into one final retune.
|
||||
_setAndTune(_currentCenter() + multiplier * step, false);
|
||||
}
|
||||
|
||||
function zoomBy(factor) {
|
||||
|
||||
@@ -265,10 +265,13 @@ const WeatherSat = (function() {
|
||||
* Stop capture
|
||||
*/
|
||||
async function stop() {
|
||||
// Optimistically update UI immediately so stop feels responsive,
|
||||
// even if the server takes time to terminate the process.
|
||||
isRunning = false;
|
||||
stopStream();
|
||||
updateStatusUI('idle', 'Stopping...');
|
||||
try {
|
||||
await fetch('/weather-sat/stop', { method: 'POST' });
|
||||
isRunning = false;
|
||||
stopStream();
|
||||
updateStatusUI('idle', 'Stopped');
|
||||
showNotification('Weather Sat', 'Capture stopped');
|
||||
} catch (err) {
|
||||
|
||||
@@ -19,7 +19,6 @@ let websdrResizeHooked = false;
|
||||
let websdrGlobeFallbackNotified = false;
|
||||
|
||||
const WEBSDR_GLOBE_SCRIPT_URLS = [
|
||||
'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js',
|
||||
'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js',
|
||||
];
|
||||
const WEBSDR_GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg';
|
||||
@@ -186,8 +185,34 @@ async function ensureWebsdrGlobeLibrary() {
|
||||
}
|
||||
|
||||
function loadWebsdrScript(src) {
|
||||
const state = getSharedGlobeScriptState();
|
||||
if (!state.promises[src]) {
|
||||
state.promises[src] = loadSharedGlobeScript(src);
|
||||
}
|
||||
return state.promises[src].catch((error) => {
|
||||
delete state.promises[src];
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function getSharedGlobeScriptState() {
|
||||
const key = '__interceptGlobeScriptState';
|
||||
if (!window[key]) {
|
||||
window[key] = {
|
||||
promises: Object.create(null),
|
||||
};
|
||||
}
|
||||
return window[key];
|
||||
}
|
||||
|
||||
function loadSharedGlobeScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const selector = `script[data-websdr-src="${src}"]`;
|
||||
const selector = [
|
||||
`script[data-intercept-globe-src="${src}"]`,
|
||||
`script[data-websdr-src="${src}"]`,
|
||||
`script[data-gps-globe-src="${src}"]`,
|
||||
`script[src="${src}"]`,
|
||||
].join(', ');
|
||||
const existing = document.querySelector(selector);
|
||||
|
||||
if (existing) {
|
||||
@@ -208,6 +233,7 @@ function loadWebsdrScript(src) {
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.crossOrigin = 'anonymous';
|
||||
script.dataset.interceptGlobeSrc = src;
|
||||
script.dataset.websdrSrc = src;
|
||||
script.onload = () => {
|
||||
script.dataset.loaded = 'true';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+147
-46
@@ -429,6 +429,7 @@
|
||||
let agentPollTimer = null; // Polling fallback for agent mode
|
||||
let isTracking = false;
|
||||
let currentFilter = 'all';
|
||||
// ICAO -> { emergency: bool, watchlist: bool, military: bool }
|
||||
let alertedAircraft = {};
|
||||
let alertsEnabled = true;
|
||||
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
|
||||
@@ -678,24 +679,64 @@
|
||||
}
|
||||
}
|
||||
|
||||
function speakAircraftAlert(kind, icao, ac, detail) {
|
||||
if (typeof VoiceAlerts === 'undefined' || typeof VoiceAlerts.speak !== 'function') return;
|
||||
|
||||
const cfg = (typeof VoiceAlerts.getConfig === 'function')
|
||||
? VoiceAlerts.getConfig()
|
||||
: { streams: {} };
|
||||
const streams = cfg && cfg.streams ? cfg.streams : {};
|
||||
const callsign = (ac && ac.callsign ? String(ac.callsign).trim() : '') || icao;
|
||||
|
||||
if (kind === 'emergency') {
|
||||
if (streams.squawks === false) return;
|
||||
const squawk = detail && detail.squawk ? ` squawk ${detail.squawk}.` : '.';
|
||||
const meaning = detail && detail.name ? ` ${detail.name}.` : '';
|
||||
VoiceAlerts.speak(`Aircraft emergency: ${callsign}.${squawk}${meaning}`, VoiceAlerts.PRIORITY.HIGH);
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === 'military') {
|
||||
if (streams.adsb_military === false) return;
|
||||
const country = detail && detail.country ? ` ${detail.country}.` : '';
|
||||
VoiceAlerts.speak(`Military aircraft detected: ${callsign}.${country}`, VoiceAlerts.PRIORITY.HIGH);
|
||||
}
|
||||
}
|
||||
|
||||
function checkAndAlertAircraft(icao, ac) {
|
||||
if (alertedAircraft[icao]) return;
|
||||
if (!alertedAircraft[icao]) {
|
||||
alertedAircraft[icao] = { emergency: false, watchlist: false, military: false };
|
||||
}
|
||||
|
||||
const alertState = alertedAircraft[icao];
|
||||
const militaryInfo = isMilitaryAircraft(icao, ac.callsign);
|
||||
const squawkInfo = checkSquawkCode(ac);
|
||||
const onWatchlist = isOnWatchlist(ac);
|
||||
|
||||
if (squawkInfo && squawkInfo.type === 'emergency') {
|
||||
alertedAircraft[icao] = 'emergency';
|
||||
playAlertSound('emergency');
|
||||
showAlertBanner(`EMERGENCY: ${squawkInfo.name} - ${ac.callsign || icao}`, '#ff0000');
|
||||
} else if (onWatchlist) {
|
||||
alertedAircraft[icao] = 'watchlist';
|
||||
if (!alertState.emergency) {
|
||||
alertState.emergency = true;
|
||||
playAlertSound('emergency');
|
||||
showAlertBanner(`EMERGENCY: ${squawkInfo.name} - ${ac.callsign || icao}`, '#ff0000');
|
||||
speakAircraftAlert('emergency', icao, ac, {
|
||||
squawk: ac.squawk,
|
||||
name: squawkInfo.name,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (onWatchlist && !alertState.watchlist) {
|
||||
alertState.watchlist = true;
|
||||
playAlertSound('military'); // Use military sound for watchlist
|
||||
showAlertBanner(`WATCHLIST: ${ac.callsign || ac.registration || icao} detected!`, '#00d4ff');
|
||||
} else if (militaryInfo.military) {
|
||||
alertedAircraft[icao] = 'military';
|
||||
} else if (militaryInfo.military && !alertState.military) {
|
||||
alertState.military = true;
|
||||
playAlertSound('military');
|
||||
showAlertBanner(`MILITARY: ${ac.callsign || icao}${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}`, '#556b2f');
|
||||
speakAircraftAlert('military', icao, ac, {
|
||||
country: militaryInfo.country || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2183,21 +2224,44 @@ sudo make install</code>
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
// Route stop through agent proxy if using remote agent
|
||||
const url = useAgent
|
||||
? `/controller/agents/${adsbCurrentAgent}/adsb/stop`
|
||||
// Route stop through the source that actually started tracking.
|
||||
const stopSource = adsbTrackingSource || (useAgent ? adsbCurrentAgent : 'local');
|
||||
const stopViaAgent = stopSource !== null && stopSource !== undefined && stopSource !== 'local';
|
||||
const url = stopViaAgent
|
||||
? `/controller/agents/${stopSource}/adsb/stop`
|
||||
: '/adsb/stop';
|
||||
await fetch(url, {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
body: JSON.stringify({ source: 'adsb_dashboard' })
|
||||
});
|
||||
const text = await response.text();
|
||||
let data = {};
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid response: ${text}`);
|
||||
}
|
||||
}
|
||||
const result = stopViaAgent && data.result ? data.result : data;
|
||||
const stopped = response.ok && (
|
||||
result.status === 'stopped' ||
|
||||
result.status === 'success' ||
|
||||
data.status === 'success'
|
||||
);
|
||||
if (!stopped) {
|
||||
throw new Error(result.message || data.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// Update agent running modes tracking
|
||||
if (useAgent && typeof agentRunningModes !== 'undefined') {
|
||||
if (stopViaAgent && typeof agentRunningModes !== 'undefined') {
|
||||
agentRunningModes = agentRunningModes.filter(m => m !== 'adsb');
|
||||
}
|
||||
} catch (err) {}
|
||||
} catch (err) {
|
||||
alert('Failed to stop ADS-B: ' + err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
stopEventStream();
|
||||
isTracking = false;
|
||||
@@ -2350,16 +2414,17 @@ sudo make install</code>
|
||||
function startEventStream() {
|
||||
if (eventSource) eventSource.close();
|
||||
|
||||
const useAgent = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local';
|
||||
const activeSource = (isTracking && adsbTrackingSource) ? adsbTrackingSource : adsbCurrentAgent;
|
||||
const useAgent = typeof activeSource !== 'undefined' && activeSource !== null && activeSource !== 'local';
|
||||
const streamUrl = useAgent ? '/controller/stream/all' : '/adsb/stream';
|
||||
|
||||
console.log(`[ADS-B] startEventStream called - adsbCurrentAgent=${adsbCurrentAgent}, useAgent=${useAgent}, streamUrl=${streamUrl}`);
|
||||
console.log(`[ADS-B] startEventStream called - activeSource=${activeSource}, useAgent=${useAgent}, streamUrl=${streamUrl}`);
|
||||
eventSource = new EventSource(streamUrl);
|
||||
|
||||
// Get agent name for filtering multi-agent stream
|
||||
let targetAgentName = null;
|
||||
if (useAgent && typeof agents !== 'undefined') {
|
||||
const agent = agents.find(a => a.id == adsbCurrentAgent);
|
||||
const agent = agents.find(a => a.id == activeSource);
|
||||
targetAgentName = agent ? agent.name : null;
|
||||
}
|
||||
|
||||
@@ -4253,30 +4318,56 @@ sudo make install</code>
|
||||
.catch(err => alert('VDL2 Error: ' + err));
|
||||
}
|
||||
|
||||
function stopVdl2() {
|
||||
const isAgentMode = vdl2CurrentAgent !== null;
|
||||
async function stopVdl2() {
|
||||
const sourceAgentId = vdl2CurrentAgent;
|
||||
const isAgentMode = sourceAgentId !== null && sourceAgentId !== undefined;
|
||||
const endpoint = isAgentMode
|
||||
? `/controller/agents/${vdl2CurrentAgent}/vdl2/stop`
|
||||
? `/controller/agents/${sourceAgentId}/vdl2/stop`
|
||||
: '/vdl2/stop';
|
||||
|
||||
fetch(endpoint, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
isVdl2Running = false;
|
||||
vdl2CurrentAgent = null;
|
||||
document.getElementById('vdl2ToggleBtn').innerHTML = '▶ START VDL2';
|
||||
document.getElementById('vdl2ToggleBtn').classList.remove('active');
|
||||
document.getElementById('vdl2PanelIndicator').classList.remove('active');
|
||||
if (vdl2EventSource) {
|
||||
vdl2EventSource.close();
|
||||
vdl2EventSource = null;
|
||||
}
|
||||
// Clear polling timer
|
||||
if (vdl2PollTimer) {
|
||||
clearInterval(vdl2PollTimer);
|
||||
vdl2PollTimer = null;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source: 'adsb_dashboard' })
|
||||
});
|
||||
const text = await response.text();
|
||||
let data = {};
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid response: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
const result = isAgentMode && data.result ? data.result : data;
|
||||
const stopped = response.ok && (
|
||||
result.status === 'stopped' ||
|
||||
result.status === 'success' ||
|
||||
data.status === 'success'
|
||||
);
|
||||
if (!stopped) {
|
||||
throw new Error(result.message || data.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
isVdl2Running = false;
|
||||
vdl2CurrentAgent = null;
|
||||
document.getElementById('vdl2ToggleBtn').innerHTML = '▶ START VDL2';
|
||||
document.getElementById('vdl2ToggleBtn').classList.remove('active');
|
||||
document.getElementById('vdl2PanelIndicator').classList.remove('active');
|
||||
if (vdl2EventSource) {
|
||||
vdl2EventSource.close();
|
||||
vdl2EventSource = null;
|
||||
}
|
||||
// Clear polling timer
|
||||
if (vdl2PollTimer) {
|
||||
clearInterval(vdl2PollTimer);
|
||||
vdl2PollTimer = null;
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Failed to stop VDL2: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync VDL2 UI state (called by syncModeUI in agents.js)
|
||||
@@ -4294,6 +4385,7 @@ sudo make install</code>
|
||||
startVdl2Stream(agentId !== null);
|
||||
}
|
||||
} else {
|
||||
vdl2CurrentAgent = null;
|
||||
btn.innerHTML = '▶ START VDL2';
|
||||
btn.classList.remove('active');
|
||||
if (indicator) indicator.classList.remove('active');
|
||||
@@ -5340,7 +5432,13 @@ sudo make install</code>
|
||||
<!-- Help Modal -->
|
||||
{% include 'partials/help-modal.html' %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=adsbvoice1"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
|
||||
<script>
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Agent Manager -->
|
||||
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
||||
@@ -5472,24 +5570,27 @@ sudo make install</code>
|
||||
|
||||
if (running) {
|
||||
isTracking = true;
|
||||
const normalizedSource = source === null || source === undefined
|
||||
? (adsbTrackingSource || 'local')
|
||||
: source;
|
||||
|
||||
// If source is an agent ID (not 'local' and not null), update adsbCurrentAgent
|
||||
// This ensures startEventStream uses the correct routing
|
||||
if (source && source !== 'local') {
|
||||
adsbCurrentAgent = source;
|
||||
if (normalizedSource !== 'local') {
|
||||
adsbCurrentAgent = normalizedSource;
|
||||
// Also update the dropdown to match
|
||||
const agentSelect = document.getElementById('agentSelect');
|
||||
if (agentSelect) {
|
||||
agentSelect.value = source;
|
||||
agentSelect.value = normalizedSource;
|
||||
}
|
||||
// Update global agent state too
|
||||
if (typeof currentAgent !== 'undefined') {
|
||||
currentAgent = source;
|
||||
currentAgent = normalizedSource;
|
||||
}
|
||||
console.log(`[ADS-B] Updated adsbCurrentAgent to ${source}`);
|
||||
console.log(`[ADS-B] Updated adsbCurrentAgent to ${normalizedSource}`);
|
||||
}
|
||||
|
||||
adsbTrackingSource = source || adsbCurrentAgent; // Track which source is active
|
||||
adsbTrackingSource = normalizedSource; // Track which source is active
|
||||
|
||||
// Update button
|
||||
if (btn) {
|
||||
@@ -5509,9 +5610,9 @@ sudo make install</code>
|
||||
if (agentSelect) agentSelect.disabled = true;
|
||||
|
||||
// Start data stream for the active source
|
||||
const isAgentSource = adsbCurrentAgent !== 'local';
|
||||
const isAgentSource = normalizedSource !== 'local';
|
||||
if (isAgentSource) {
|
||||
console.log(`[ADS-B] Starting data stream from agent ${adsbCurrentAgent}`);
|
||||
console.log(`[ADS-B] Starting data stream from agent ${normalizedSource}`);
|
||||
startEventStream();
|
||||
drawRangeRings();
|
||||
startSessionTimer();
|
||||
|
||||
+507
-94
@@ -79,36 +79,90 @@
|
||||
gps: "{{ url_for('static', filename='css/modes/gps.css') }}",
|
||||
subghz: "{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9",
|
||||
bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4",
|
||||
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}"
|
||||
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
|
||||
wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}",
|
||||
morse: "{{ url_for('static', filename='css/modes/morse.css') }}"
|
||||
};
|
||||
window.INTERCEPT_MODE_STYLE_LOADED = {};
|
||||
window.INTERCEPT_MODE_STYLE_PROMISES = {};
|
||||
window.ensureModeStyles = function(mode) {
|
||||
const href = window.INTERCEPT_MODE_STYLE_MAP ? window.INTERCEPT_MODE_STYLE_MAP[mode] : null;
|
||||
if (!href) return;
|
||||
if (!href) return Promise.resolve();
|
||||
if (window.INTERCEPT_MODE_STYLE_LOADED[href] === 'loaded') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (window.INTERCEPT_MODE_STYLE_PROMISES[href]) {
|
||||
return window.INTERCEPT_MODE_STYLE_PROMISES[href];
|
||||
}
|
||||
const absHref = new URL(href, window.location.href).href;
|
||||
const existing = Array.from(document.querySelectorAll('link[data-mode-style]'))
|
||||
.find((link) => link.href === absHref);
|
||||
if (existing) {
|
||||
if (existing && existing.sheet) {
|
||||
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded';
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (window.INTERCEPT_MODE_STYLE_LOADED[href] === 'loading') return;
|
||||
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loading';
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = href;
|
||||
link.dataset.modeStyle = mode;
|
||||
link.onload = () => {
|
||||
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded';
|
||||
};
|
||||
link.onerror = () => {
|
||||
delete window.INTERCEPT_MODE_STYLE_LOADED[href];
|
||||
try {
|
||||
link.remove();
|
||||
} catch (_) {}
|
||||
};
|
||||
document.head.appendChild(link);
|
||||
const link = existing || document.createElement('link');
|
||||
if (!existing) {
|
||||
link.rel = 'stylesheet';
|
||||
link.href = href;
|
||||
link.dataset.modeStyle = mode;
|
||||
}
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const onLoad = () => {
|
||||
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded';
|
||||
delete window.INTERCEPT_MODE_STYLE_PROMISES[href];
|
||||
resolve();
|
||||
};
|
||||
const onError = () => {
|
||||
delete window.INTERCEPT_MODE_STYLE_LOADED[href];
|
||||
delete window.INTERCEPT_MODE_STYLE_PROMISES[href];
|
||||
try {
|
||||
link.remove();
|
||||
} catch (_) {}
|
||||
reject(new Error(`failed to load mode stylesheet: ${mode}`));
|
||||
};
|
||||
link.addEventListener('load', onLoad, { once: true });
|
||||
link.addEventListener('error', onError, { once: true });
|
||||
if (existing) {
|
||||
// Existing links may have finished loading before listeners attached.
|
||||
if (existing.sheet) onLoad();
|
||||
} else {
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
});
|
||||
window.INTERCEPT_MODE_STYLE_PROMISES[href] = promise;
|
||||
return promise;
|
||||
};
|
||||
// Start loading a deep-linked mode stylesheet as early as possible.
|
||||
(function preloadQueryModeStyles() {
|
||||
const queryMode = new URLSearchParams(window.location.search).get('mode');
|
||||
const mode = queryMode === 'listening' ? 'waterfall' : queryMode;
|
||||
if (!mode) return;
|
||||
window.ensureModeStyles(mode).catch(() => {});
|
||||
})();
|
||||
// Warm remaining lazy mode styles in the background to avoid first-switch FOUC.
|
||||
(function warmModeStylesInBackground() {
|
||||
const modeMap = window.INTERCEPT_MODE_STYLE_MAP || {};
|
||||
const queryMode = new URLSearchParams(window.location.search).get('mode');
|
||||
const selectedMode = queryMode === 'listening' ? 'waterfall' : queryMode;
|
||||
const modes = Object.keys(modeMap).filter((mode) => mode !== selectedMode);
|
||||
if (!modes.length) return;
|
||||
|
||||
const warm = function () {
|
||||
modes.forEach(function (mode, index) {
|
||||
setTimeout(function () {
|
||||
window.ensureModeStyles(mode).catch(() => {});
|
||||
}, index * 40);
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
window.requestIdleCallback(warm, { timeout: 2000 });
|
||||
} else {
|
||||
setTimeout(warm, 600);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
@@ -141,6 +195,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="welcome-container">
|
||||
<button type="button" class="welcome-settings-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Header Section -->
|
||||
<div class="welcome-header">
|
||||
<div class="welcome-logo">
|
||||
@@ -218,6 +278,10 @@
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.5"/><path d="M2 21h20" opacity="0.3"/></svg></span>
|
||||
<span class="mode-name">Waterfall</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('morse')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg></span>
|
||||
<span class="mode-name">Morse</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -264,6 +328,10 @@
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span>
|
||||
<span class="mode-name">HF SSTV</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('wefax')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span>
|
||||
<span class="mode-name">WeFax</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('spaceweather')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>
|
||||
<span class="mode-name">Space Wx</span>
|
||||
@@ -616,6 +684,10 @@
|
||||
|
||||
{% include 'partials/modes/gps.html' %}
|
||||
|
||||
{% include 'partials/modes/wefax.html' %}
|
||||
|
||||
{% include 'partials/modes/morse.html' %}
|
||||
|
||||
{% include 'partials/modes/space-weather.html' %}
|
||||
|
||||
{% include 'partials/modes/tscm.html' %}
|
||||
@@ -1182,23 +1254,25 @@
|
||||
<!-- GPS Receiver Dashboard -->
|
||||
<div id="gpsVisuals" class="gps-visuals-container" style="display: none;">
|
||||
<div class="gps-visuals-top">
|
||||
<!-- Sky View Polar Plot -->
|
||||
<!-- Sky View Globe -->
|
||||
<div class="gps-skyview-panel">
|
||||
<h4>Satellite Sky View</h4>
|
||||
<h4>Satellite Globe View</h4>
|
||||
<div class="gps-skyview-canvas-wrap" id="gpsSkyViewWrap">
|
||||
<canvas id="gpsSkyCanvas" width="400" height="400" aria-label="GPS satellite sky globe"></canvas>
|
||||
<div id="gpsSkyGlobe" class="gps-sky-globe" aria-label="GPS satellite globe"></div>
|
||||
<canvas id="gpsSkyCanvas" width="400" height="400" aria-label="GPS satellite sky fallback globe"></canvas>
|
||||
<div class="gps-sky-overlay" id="gpsSkyOverlay" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="gps-sky-hint">Drag to orbit | Scroll to zoom</div>
|
||||
<div class="gps-sky-hint">Drag to orbit globe | Scroll to zoom | Hover satellites for details</div>
|
||||
<div class="gps-legend">
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ffffff;"></span> Observer</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00d4ff;"></span> GPS</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00ff88;"></span> GLONASS</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ff8800;"></span> Galileo</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ff4466;"></span> BeiDou</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ffdd00;"></span> SBAS</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#cc66ff;"></span> QZSS</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00d4ff;"></span> Used (filled)</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:transparent; border:1.5px solid #00d4ff;"></span> Unused (hollow)</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00d4ff;"></span> Used (bright)</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:rgba(0,212,255,0.45);"></span> Unused (dim)</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Position Info -->
|
||||
@@ -2527,6 +2601,137 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WeFax Decoder Dashboard -->
|
||||
<div id="wefaxVisuals" class="wefax-visuals-container" style="display: none;">
|
||||
<!-- Stats Strip -->
|
||||
<div class="wefax-stats-strip">
|
||||
<div class="wefax-strip-group">
|
||||
<div class="wefax-strip-status">
|
||||
<span class="wefax-strip-dot idle" id="wefaxStripDot"></span>
|
||||
<span class="wefax-strip-status-text" id="wefaxStripStatus">Idle</span>
|
||||
</div>
|
||||
<button class="wefax-strip-btn start" id="wefaxStartBtn" onclick="WeFax.start()">Start</button>
|
||||
<button class="wefax-strip-btn stop" id="wefaxStopBtn" onclick="WeFax.stop()" style="display: none;">Stop</button>
|
||||
<label class="wefax-schedule-toggle" title="Auto-capture broadcasts">
|
||||
<input type="checkbox" id="wefaxStripAutoSchedule"
|
||||
onchange="WeFax.toggleScheduler(this)">
|
||||
<span>Auto</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="wefax-strip-divider"></div>
|
||||
<div class="wefax-strip-group">
|
||||
<div class="wefax-strip-stat">
|
||||
<span class="wefax-strip-value accent-amber" id="wefaxStripFreq">---</span>
|
||||
<span class="wefax-strip-label">KHZ</span>
|
||||
</div>
|
||||
<div class="wefax-strip-stat">
|
||||
<span class="wefax-strip-value" id="wefaxStripLines">0</span>
|
||||
<span class="wefax-strip-label">LINES</span>
|
||||
</div>
|
||||
<div class="wefax-strip-stat">
|
||||
<span class="wefax-strip-value" id="wefaxStripImageCount">0</span>
|
||||
<span class="wefax-strip-label">IMAGES</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Countdown + Timeline -->
|
||||
<div class="wefax-countdown-bar" id="wefaxCountdownBar" style="display: none;">
|
||||
<div class="wefax-countdown-next">
|
||||
<div class="wefax-countdown-boxes" id="wefaxCountdownBoxes">
|
||||
<div class="wefax-countdown-box"><span class="wefax-cd-value" id="wefaxCdHours">--</span><span class="wefax-cd-unit">HRS</span></div>
|
||||
<div class="wefax-countdown-box"><span class="wefax-cd-value" id="wefaxCdMins">--</span><span class="wefax-cd-unit">MIN</span></div>
|
||||
<div class="wefax-countdown-box"><span class="wefax-cd-value" id="wefaxCdSecs">--</span><span class="wefax-cd-unit">SEC</span></div>
|
||||
</div>
|
||||
<div class="wefax-countdown-info" id="wefaxCountdownInfo">
|
||||
<span class="wefax-countdown-content" id="wefaxCountdownContent">--</span>
|
||||
<span class="wefax-countdown-detail" id="wefaxCountdownDetail">Select a station</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wefax-timeline" id="wefaxTimeline">
|
||||
<div class="wefax-timeline-track" id="wefaxTimelineTrack"></div>
|
||||
<div class="wefax-timeline-cursor" id="wefaxTimelineCursor"></div>
|
||||
<div class="wefax-timeline-labels">
|
||||
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio Waveform Scope -->
|
||||
<div id="wefaxScopePanel" style="display: none;">
|
||||
<div style="background: #0a0a0a; border: 1px solid #2e2a1a; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||||
<span>Audio Waveform</span>
|
||||
<div style="display: flex; gap: 14px;">
|
||||
<span>RMS: <span id="wefaxScopeRmsLabel" style="color: #ffaa00; font-variant-numeric: tabular-nums;">0</span></span>
|
||||
<span>PEAK: <span id="wefaxScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||||
<span id="wefaxScopeStatusLabel" style="color: #444;">IDLE</span>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="wefaxScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Timeline -->
|
||||
<div class="wefax-schedule-panel">
|
||||
<div class="wefax-schedule-header">
|
||||
<span class="wefax-schedule-title">Broadcast Schedule</span>
|
||||
<span id="wefaxStatusText" style="font-family: var(--font-mono); font-size: 10px; color: var(--text-dim);"></span>
|
||||
</div>
|
||||
<div id="wefaxScheduleTimeline">
|
||||
<div class="wefax-schedule-empty">Select a station to see broadcast schedule</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content: Live Preview + Gallery -->
|
||||
<div class="wefax-main-row">
|
||||
<div class="wefax-live-section">
|
||||
<div class="wefax-live-header">
|
||||
<div class="wefax-live-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align: -2px; margin-right: 4px; color: #ffaa00;">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
Live Decode
|
||||
</div>
|
||||
</div>
|
||||
<div class="wefax-live-content" id="wefaxLiveContent">
|
||||
<div class="wefax-idle-state" id="wefaxIdleState">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
<h4>WeFax Decoder</h4>
|
||||
<p>Select a station and click Start to decode weather fax transmissions</p>
|
||||
</div>
|
||||
<img id="wefaxLivePreview" class="wefax-live-preview" style="display: none;" alt="WeFax decode in progress">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wefax-gallery-section">
|
||||
<div class="wefax-gallery-header">
|
||||
<div class="wefax-gallery-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align: -2px; margin-right: 4px; color: #ffaa00;">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
Decoded Images
|
||||
</div>
|
||||
<div class="wefax-gallery-controls">
|
||||
<span class="wefax-gallery-count" id="wefaxImageCount">0</span>
|
||||
<button class="wefax-gallery-clear-btn" onclick="WeFax.deleteAllImages()" title="Delete all images">Clear All</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wefax-gallery-grid" id="wefaxGallery">
|
||||
<div class="wefax-gallery-empty">No images decoded yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Space Weather Dashboard -->
|
||||
<div id="spaceWeatherVisuals" class="sw-visuals-container" style="display: none;">
|
||||
<!-- Header metrics strip -->
|
||||
@@ -2809,6 +3014,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
||||
<div class="recon-panel collapsed" id="reconPanel">
|
||||
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
|
||||
@@ -2864,6 +3070,42 @@
|
||||
|
||||
<div id="sensorTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
||||
|
||||
<!-- Morse Signal Scope -->
|
||||
<div id="morseScopePanel" style="display: none; margin-bottom: 12px;">
|
||||
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||||
<span>CW Tone Detection</span>
|
||||
<div style="display: flex; gap: 14px;">
|
||||
<span>TONE: <span id="morseScopeToneLabel" style="color: #0f0; font-variant-numeric: tabular-nums;">--</span></span>
|
||||
<span>THRESH: <span id="morseScopeThreshLabel" style="color: #fa0; font-variant-numeric: tabular-nums;">--</span></span>
|
||||
<span id="morseScopeStatusLabel" style="color: #444;">IDLE</span>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="morseScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Morse Decoded Output -->
|
||||
<div id="morseOutputPanel" style="display: none; margin-bottom: 12px;">
|
||||
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||||
<span>Decoded Text</span>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button class="btn btn-sm btn-ghost" onclick="MorseMode.exportTxt()">TXT</button>
|
||||
<button class="btn btn-sm btn-ghost" onclick="MorseMode.exportCsv()">CSV</button>
|
||||
<button class="btn btn-sm btn-ghost" id="morseCopyBtn" onclick="MorseMode.copyToClipboard()">Copy</button>
|
||||
<button class="btn btn-sm btn-ghost" onclick="MorseMode.clearText()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="morseDecodedText" class="morse-decoded-panel"></div>
|
||||
<div class="morse-status-bar">
|
||||
<span class="status-item" id="morseStatusBarWpm">15 WPM</span>
|
||||
<span class="status-item" id="morseStatusBarTone">700 Hz</span>
|
||||
<span class="status-item" id="morseStatusBarChars">0 chars decoded</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="output-content signal-feed" id="output">
|
||||
<div class="placeholder signal-empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -2932,6 +3174,8 @@
|
||||
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/wefax.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/morse.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
|
||||
@@ -3075,6 +3319,7 @@
|
||||
sstv: { label: 'ISS SSTV', indicator: 'ISS SSTV', outputTitle: 'ISS SSTV Decoder', group: 'space' },
|
||||
weathersat: { label: 'Weather Sat', indicator: 'WEATHER SAT', outputTitle: 'Weather Satellite Decoder', group: 'space' },
|
||||
sstv_general: { label: 'HF SSTV', indicator: 'HF SSTV', outputTitle: 'HF SSTV Decoder', group: 'space' },
|
||||
wefax: { label: 'WeFax', indicator: 'WEFAX', outputTitle: 'Weather Fax Decoder', group: 'space' },
|
||||
spaceweather: { label: 'Space Weather', indicator: 'SPACE WX', outputTitle: 'Space Weather Monitor', group: 'space' },
|
||||
wifi: { label: 'WiFi', indicator: 'WIFI', outputTitle: 'WiFi Scanner', group: 'wireless' },
|
||||
bluetooth: { label: 'Bluetooth', indicator: 'BLUETOOTH', outputTitle: 'Bluetooth Scanner', group: 'wireless' },
|
||||
@@ -3084,6 +3329,7 @@
|
||||
spystations: { label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel' },
|
||||
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' },
|
||||
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
|
||||
morse: { label: 'Morse', indicator: 'MORSE', outputTitle: 'CW/Morse Decoder', group: 'signals' },
|
||||
};
|
||||
const validModes = new Set(Object.keys(modeCatalog));
|
||||
window.interceptModeCatalog = Object.assign({}, modeCatalog);
|
||||
@@ -3740,6 +3986,11 @@
|
||||
const previousMode = currentMode;
|
||||
if (mode === 'listening') mode = 'waterfall';
|
||||
if (!validModes.has(mode)) mode = 'pager';
|
||||
const styleReadyPromise = (typeof window.ensureModeStyles === 'function')
|
||||
? Promise.resolve(window.ensureModeStyles(mode)).catch((err) => {
|
||||
console.warn(`[ModeSwitch] style load failed for ${mode}: ${err?.message || err}`);
|
||||
})
|
||||
: Promise.resolve();
|
||||
// Only stop local scans if in local mode (not agent mode)
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
const stopPhaseStartMs = performance.now();
|
||||
@@ -3783,6 +4034,7 @@
|
||||
stopTaskCount = stopTasks.length;
|
||||
}
|
||||
const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs);
|
||||
await styleReadyPromise;
|
||||
|
||||
// Clean up SubGHz SSE connection when leaving the mode
|
||||
if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') {
|
||||
@@ -3808,10 +4060,6 @@
|
||||
closeAllDropdowns();
|
||||
updateDropdownActiveState();
|
||||
|
||||
if (typeof window.ensureModeStyles === 'function') {
|
||||
window.ensureModeStyles(mode);
|
||||
}
|
||||
|
||||
// Remove active from all nav buttons, then add to the correct one
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.mode === mode);
|
||||
@@ -3826,6 +4074,7 @@
|
||||
document.getElementById('sstvMode')?.classList.toggle('active', mode === 'sstv');
|
||||
document.getElementById('weatherSatMode')?.classList.toggle('active', mode === 'weathersat');
|
||||
document.getElementById('sstvGeneralMode')?.classList.toggle('active', mode === 'sstv_general');
|
||||
document.getElementById('wefaxMode')?.classList.toggle('active', mode === 'wefax');
|
||||
document.getElementById('gpsMode')?.classList.toggle('active', mode === 'gps');
|
||||
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
|
||||
@@ -3839,6 +4088,7 @@
|
||||
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
|
||||
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
|
||||
document.getElementById('waterfallMode')?.classList.toggle('active', mode === 'waterfall');
|
||||
document.getElementById('morseMode')?.classList.toggle('active', mode === 'morse');
|
||||
|
||||
|
||||
const pagerStats = document.getElementById('pagerStats');
|
||||
@@ -3877,6 +4127,7 @@
|
||||
const websdrVisuals = document.getElementById('websdrVisuals');
|
||||
const subghzVisuals = document.getElementById('subghzVisuals');
|
||||
const btLocateVisuals = document.getElementById('btLocateVisuals');
|
||||
const wefaxVisuals = document.getElementById('wefaxVisuals');
|
||||
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
||||
const waterfallVisuals = document.getElementById('waterfallVisuals');
|
||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
@@ -3893,6 +4144,7 @@
|
||||
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
|
||||
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
|
||||
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
|
||||
if (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none';
|
||||
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
|
||||
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none';
|
||||
|
||||
@@ -3916,6 +4168,10 @@
|
||||
const sensorTimelineContainer = document.getElementById('sensorTimelineContainer');
|
||||
if (pagerTimelineContainer) pagerTimelineContainer.style.display = mode === 'pager' ? 'block' : 'none';
|
||||
if (sensorTimelineContainer) sensorTimelineContainer.style.display = mode === 'sensor' ? 'block' : 'none';
|
||||
const morseScopePanel = document.getElementById('morseScopePanel');
|
||||
const morseOutputPanel = document.getElementById('morseOutputPanel');
|
||||
if (morseScopePanel && mode !== 'morse') morseScopePanel.style.display = 'none';
|
||||
if (morseOutputPanel && mode !== 'morse') morseOutputPanel.style.display = 'none';
|
||||
|
||||
// Update output panel title based on mode
|
||||
const outputTitle = document.getElementById('outputTitle');
|
||||
@@ -3940,11 +4196,16 @@
|
||||
if (typeof WeatherSat !== 'undefined' && WeatherSat.suspend) WeatherSat.suspend();
|
||||
}
|
||||
|
||||
// Suspend WeFax background streams when leaving the mode
|
||||
if (mode !== 'wefax') {
|
||||
if (typeof WeFax !== 'undefined' && WeFax.destroy) WeFax.destroy();
|
||||
}
|
||||
|
||||
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
const reconPanel = document.getElementById('reconPanel');
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') {
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') {
|
||||
if (reconPanel) reconPanel.style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
@@ -3964,7 +4225,27 @@
|
||||
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
||||
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general') ? 'block' : 'none';
|
||||
if (rtlDeviceSection) {
|
||||
rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'morse') ? 'block' : 'none';
|
||||
// Save original sidebar position of SDR device section (once)
|
||||
if (!rtlDeviceSection._origParent) {
|
||||
rtlDeviceSection._origParent = rtlDeviceSection.parentNode;
|
||||
rtlDeviceSection._origNext = rtlDeviceSection.nextElementSibling;
|
||||
}
|
||||
// For morse mode, move SDR device section inside the morse panel after the title
|
||||
const morsePanel = document.getElementById('morseMode');
|
||||
if (mode === 'morse' && morsePanel) {
|
||||
const firstSection = morsePanel.querySelector('.section');
|
||||
if (firstSection) firstSection.after(rtlDeviceSection);
|
||||
} else if (rtlDeviceSection._origParent && rtlDeviceSection.parentNode !== rtlDeviceSection._origParent) {
|
||||
// Restore to original sidebar position when leaving morse mode
|
||||
if (rtlDeviceSection._origNext) {
|
||||
rtlDeviceSection._origNext.before(rtlDeviceSection);
|
||||
} else {
|
||||
rtlDeviceSection._origParent.appendChild(rtlDeviceSection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle mode-specific tool status displays
|
||||
const toolStatusPager = document.getElementById('toolStatusPager');
|
||||
@@ -3975,7 +4256,7 @@
|
||||
// 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 === '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 (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';
|
||||
|
||||
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
|
||||
@@ -4042,10 +4323,14 @@
|
||||
setTimeout(() => {
|
||||
if (typeof BtLocate !== 'undefined' && BtLocate.invalidateMap) BtLocate.invalidateMap();
|
||||
}, 320);
|
||||
} else if (mode === 'wefax') {
|
||||
WeFax.init();
|
||||
} else if (mode === 'spaceweather') {
|
||||
SpaceWeather.init();
|
||||
} else if (mode === 'waterfall') {
|
||||
if (typeof Waterfall !== 'undefined') Waterfall.init();
|
||||
} else if (mode === 'morse') {
|
||||
MorseMode.init();
|
||||
}
|
||||
|
||||
// Destroy Waterfall WebSocket when leaving SDR receiver modes
|
||||
@@ -5449,10 +5734,19 @@
|
||||
renderSdrStatus(devices);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to fetch SDR status:', err);
|
||||
const transient = (typeof window.isTransientOrOffline === 'function' && window.isTransientOrOffline(err)) ||
|
||||
(typeof navigator !== 'undefined' && navigator.onLine === false) ||
|
||||
/failed to fetch|network io suspended|networkerror|timeout/i.test(String((err && err.message) || err || ''));
|
||||
if (!transient) {
|
||||
console.error('Failed to fetch SDR status:', err);
|
||||
}
|
||||
const container = document.getElementById('sdrStatusList');
|
||||
if (container) {
|
||||
container.innerHTML = '<div style="padding: 8px; color: #ff6666; font-size: 11px; text-align: center;">Error loading status</div>';
|
||||
if (transient) {
|
||||
container.innerHTML = '<div style="padding: 8px; color: #888; font-size: 11px; text-align: center;">Status temporarily unavailable</div>';
|
||||
} else {
|
||||
container.innerHTML = '<div style="padding: 8px; color: #ff6666; font-size: 11px; text-align: center;">Error loading status</div>';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -9083,19 +9377,26 @@
|
||||
return R * c;
|
||||
}
|
||||
|
||||
function aprsHasValidCoordinates(lat, lon) {
|
||||
return lat != null && lon != null &&
|
||||
Number.isFinite(Number(lat)) && Number.isFinite(Number(lon));
|
||||
}
|
||||
|
||||
// Update APRS user location from GPS
|
||||
function updateAprsUserLocation(position) {
|
||||
if (!position || !position.latitude || !position.longitude) return;
|
||||
const lat = Number(position && position.latitude);
|
||||
const lon = Number(position && position.longitude);
|
||||
if (!aprsHasValidCoordinates(lat, lon)) return;
|
||||
|
||||
aprsUserLocation.lat = position.latitude;
|
||||
aprsUserLocation.lon = position.longitude;
|
||||
aprsUserLocation.lat = lat;
|
||||
aprsUserLocation.lon = lon;
|
||||
|
||||
// Update user marker on map
|
||||
if (aprsMap) {
|
||||
if (aprsUserMarker) {
|
||||
aprsUserMarker.setLatLng([position.latitude, position.longitude]);
|
||||
aprsUserMarker.setLatLng([lat, lon]);
|
||||
} else {
|
||||
aprsUserMarker = L.marker([position.latitude, position.longitude], {
|
||||
aprsUserMarker = L.marker([lat, lon], {
|
||||
icon: L.divIcon({
|
||||
className: 'aprs-user-marker',
|
||||
html: '<div style="width: 14px; height: 14px; background: #ff0; border: 2px solid #000; border-radius: 50%; box-shadow: 0 0 10px #ff0;"></div>',
|
||||
@@ -9108,7 +9409,7 @@
|
||||
|
||||
// Center map on first GPS fix
|
||||
if (!aprsMap._gpsInitialized) {
|
||||
aprsMap.setView([position.latitude, position.longitude], 8);
|
||||
aprsMap.setView([lat, lon], 8);
|
||||
aprsMap._gpsInitialized = true;
|
||||
}
|
||||
}
|
||||
@@ -9123,7 +9424,7 @@
|
||||
|
||||
// Update distances for all stations in the list
|
||||
function updateAprsStationDistances() {
|
||||
if (!aprsUserLocation.lat || !aprsUserLocation.lon) return;
|
||||
if (!aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon)) return;
|
||||
|
||||
// Update station list items
|
||||
const listEl = document.getElementById('aprsStationList');
|
||||
@@ -9180,9 +9481,14 @@
|
||||
if (!mapContainer) return;
|
||||
|
||||
// Use GPS location if available, otherwise default to center of US
|
||||
const initialLat = aprsUserLocation.lat || gpsLastPosition?.latitude || 39.8283;
|
||||
const initialLon = aprsUserLocation.lon || gpsLastPosition?.longitude || -98.5795;
|
||||
const initialZoom = (aprsUserLocation.lat || gpsLastPosition?.latitude) ? 8 : 4;
|
||||
const gpsLat = Number(gpsLastPosition && gpsLastPosition.latitude);
|
||||
const gpsLon = Number(gpsLastPosition && gpsLastPosition.longitude);
|
||||
const hasUserLocation = aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon);
|
||||
const hasGpsLocation = aprsHasValidCoordinates(gpsLat, gpsLon);
|
||||
|
||||
const initialLat = hasUserLocation ? aprsUserLocation.lat : (hasGpsLocation ? gpsLat : 39.8283);
|
||||
const initialLon = hasUserLocation ? aprsUserLocation.lon : (hasGpsLocation ? gpsLon : -98.5795);
|
||||
const initialZoom = (hasUserLocation || hasGpsLocation) ? 8 : 4;
|
||||
|
||||
aprsMap = L.map('aprsMap').setView([initialLat, initialLon], initialZoom);
|
||||
window.aprsMap = aprsMap;
|
||||
@@ -9203,8 +9509,8 @@
|
||||
}
|
||||
|
||||
// Add user marker if GPS position is already available
|
||||
if (gpsConnected && gpsLastPosition && gpsLastPosition.latitude && gpsLastPosition.longitude) {
|
||||
updateAprsUserLocation(gpsLastPosition);
|
||||
if (gpsConnected && hasGpsLocation) {
|
||||
updateAprsUserLocation({ latitude: gpsLat, longitude: gpsLon });
|
||||
aprsMap._gpsInitialized = true;
|
||||
}
|
||||
|
||||
@@ -9251,6 +9557,104 @@
|
||||
// APRS mode polling timer for agent mode
|
||||
let aprsPollTimer = null;
|
||||
let aprsCurrentAgent = null;
|
||||
const aprsAgentStationSignatures = new Map();
|
||||
|
||||
function resetAprsAgentStationTracking() {
|
||||
aprsAgentStationSignatures.clear();
|
||||
}
|
||||
|
||||
function extractAprsStationsFromPayload(payload) {
|
||||
if (!payload) return [];
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (Array.isArray(payload.stations)) return payload.stations;
|
||||
if (Array.isArray(payload.data)) return payload.data;
|
||||
if (payload.data && Array.isArray(payload.data.stations)) return payload.data.stations;
|
||||
if (payload.data && Array.isArray(payload.data.data)) return payload.data.data;
|
||||
if (payload.result && Array.isArray(payload.result.stations)) return payload.result.stations;
|
||||
if (payload.result && Array.isArray(payload.result.data)) return payload.result.data;
|
||||
if (payload.data && payload.data.result && Array.isArray(payload.data.result.stations)) {
|
||||
return payload.data.result.stations;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getAprsStationSignature(station) {
|
||||
if (!station || typeof station !== 'object') return '';
|
||||
const receivedAt = station.received_at || station.last_seen || station.timestamp || '';
|
||||
const lat = station.lat ?? station.latitude ?? '';
|
||||
const lon = station.lon ?? station.longitude ?? '';
|
||||
const payloadHint = station.raw || station.comment || station.path || '';
|
||||
return `${receivedAt}|${lat},${lon}|${payloadHint}`;
|
||||
}
|
||||
|
||||
function processAprsAgentStations(stations, agentName) {
|
||||
if (!Array.isArray(stations) || stations.length === 0) return;
|
||||
|
||||
stations.forEach((station) => {
|
||||
const callsign = String(station && station.callsign ? station.callsign : '').trim();
|
||||
if (!callsign) return;
|
||||
const lat = station.lat ?? station.latitude ?? null;
|
||||
const lon = station.lon ?? station.longitude ?? null;
|
||||
|
||||
const signature = getAprsStationSignature(station);
|
||||
if (aprsAgentStationSignatures.get(callsign) === signature) return;
|
||||
aprsAgentStationSignatures.set(callsign, signature);
|
||||
|
||||
aprsPacketCount++;
|
||||
document.getElementById('aprsPacketCount').textContent = aprsPacketCount;
|
||||
document.getElementById('aprsStripPackets').textContent = aprsPacketCount;
|
||||
|
||||
const dot = document.getElementById('aprsStripDot');
|
||||
if (dot && !dot.classList.contains('tracking')) {
|
||||
updateAprsStatus('tracking');
|
||||
}
|
||||
|
||||
processAprsPacket({
|
||||
type: 'aprs',
|
||||
...station,
|
||||
lat,
|
||||
lon,
|
||||
callsign,
|
||||
agent_name: station.agent_name || agentName || 'Remote Agent'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAprsStationSnapshot(isAgentMode = false) {
|
||||
try {
|
||||
const endpoint = (isAgentMode && aprsCurrentAgent)
|
||||
? `/controller/agents/${aprsCurrentAgent}/aprs/data`
|
||||
: '/aprs/stations';
|
||||
const response = await fetch(endpoint);
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
const stations = extractAprsStationsFromPayload(payload);
|
||||
if (!Array.isArray(stations) || stations.length === 0) return;
|
||||
if (isAgentMode) {
|
||||
processAprsAgentStations(stations, payload.agent_name);
|
||||
return;
|
||||
}
|
||||
|
||||
stations.forEach((station) => {
|
||||
const callsign = String(station && station.callsign ? station.callsign : '').trim();
|
||||
if (!callsign) return;
|
||||
const packet = {
|
||||
type: 'aprs',
|
||||
...station,
|
||||
callsign,
|
||||
lat: station.lat ?? station.latitude ?? null,
|
||||
lon: station.lon ?? station.longitude ?? null,
|
||||
packet_type: station.packet_type || 'position',
|
||||
};
|
||||
if (aprsHasValidCoordinates(packet.lat, packet.lon) && aprsMap) {
|
||||
updateAprsMarker(packet);
|
||||
}
|
||||
updateAprsStationList(packet);
|
||||
});
|
||||
} catch (err) {
|
||||
console.debug('APRS snapshot load failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function startAprs() {
|
||||
// Get values from function bar controls
|
||||
@@ -9300,6 +9704,16 @@
|
||||
isAprsRunning = true;
|
||||
aprsPacketCount = 0;
|
||||
aprsStationCount = 0;
|
||||
resetAprsAgentStationTracking();
|
||||
|
||||
if (aprsMap) {
|
||||
Object.values(aprsMarkers).forEach((marker) => {
|
||||
try {
|
||||
aprsMap.removeLayer(marker);
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
aprsMarkers = {};
|
||||
|
||||
// Initialize APRS filter bar and clear history
|
||||
const filterContainer = document.getElementById('aprsFilterBarContainer');
|
||||
@@ -9312,6 +9726,12 @@
|
||||
|
||||
// Clear existing station cards
|
||||
stationList.innerHTML = '<div class="signal-cards-placeholder" style="padding: 20px; text-align: center; color: var(--text-muted);">Waiting for stations...</div>';
|
||||
const packetLog = document.getElementById('aprsPacketLog');
|
||||
if (packetLog) {
|
||||
packetLog.innerHTML = '<div style="color: var(--text-muted);">Waiting for packets...</div>';
|
||||
}
|
||||
document.getElementById('aprsPacketCount').textContent = '0';
|
||||
document.getElementById('aprsStationCount').textContent = '0';
|
||||
|
||||
// Update function bar buttons
|
||||
document.getElementById('aprsStripStartBtn').style.display = 'none';
|
||||
@@ -9332,6 +9752,9 @@
|
||||
if (customFreqInput) customFreqInput.disabled = true;
|
||||
startAprsMeterCheck();
|
||||
startAprsStream(isAgentMode);
|
||||
// Backfill current stations in case position packets arrived before
|
||||
// map initialization or SSE attachment.
|
||||
loadAprsStationSnapshot(isAgentMode);
|
||||
} else {
|
||||
alert('APRS Error: ' + (scanResult.message || scanResult.error || 'Failed to start'));
|
||||
updateAprsStatus('error');
|
||||
@@ -9343,7 +9766,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
function stopAprs() {
|
||||
async function stopAprs() {
|
||||
const isAgentMode = aprsCurrentAgent !== null;
|
||||
const endpoint = isAgentMode
|
||||
? `/controller/agents/${aprsCurrentAgent}/aprs/stop`
|
||||
@@ -9352,9 +9775,9 @@
|
||||
|
||||
isAprsRunning = false;
|
||||
aprsCurrentAgent = null;
|
||||
document.getElementById('aprsStripStartBtn').style.display = 'inline-block';
|
||||
resetAprsAgentStationTracking();
|
||||
document.getElementById('aprsStripStopBtn').style.display = 'none';
|
||||
document.getElementById('aprsMapStatus').textContent = 'STANDBY';
|
||||
document.getElementById('aprsMapStatus').textContent = 'STOPPING';
|
||||
document.getElementById('aprsMapStatus').style.color = '';
|
||||
updateAprsStatus('standby');
|
||||
document.getElementById('aprsStripFreq').textContent = '--';
|
||||
@@ -9377,7 +9800,9 @@
|
||||
aprsPollTimer = null;
|
||||
}
|
||||
|
||||
return postStopRequest(endpoint, timeoutMs);
|
||||
await postStopRequest(endpoint, timeoutMs);
|
||||
document.getElementById('aprsStripStartBtn').style.display = 'inline-block';
|
||||
document.getElementById('aprsMapStatus').textContent = 'STANDBY';
|
||||
}
|
||||
|
||||
function startAprsStream(isAgentMode = false) {
|
||||
@@ -9385,7 +9810,7 @@
|
||||
|
||||
// Use different stream endpoint for agent mode
|
||||
const streamUrl = isAgentMode ? '/controller/stream/all' : '/aprs/stream';
|
||||
aprsEventSource = new EventSource(streamUrl);
|
||||
aprsEventSource = new EventSource(streamUrl + (streamUrl.includes('?') ? '&' : '?') + 't=' + Date.now());
|
||||
|
||||
aprsEventSource.onmessage = function (e) {
|
||||
const data = JSON.parse(e.data);
|
||||
@@ -9407,6 +9832,9 @@
|
||||
processAprsPacket(payload);
|
||||
} else if (payload.type === 'meter') {
|
||||
updateAprsMeter(payload.level);
|
||||
} else {
|
||||
const stations = extractAprsStationsFromPayload(payload);
|
||||
processAprsAgentStations(stations, data.agent_name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -9440,12 +9868,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Track last station count for polling
|
||||
let lastAprsStationCount = 0;
|
||||
|
||||
function startAprsPolling() {
|
||||
if (aprsPollTimer) return;
|
||||
lastAprsStationCount = 0;
|
||||
resetAprsAgentStationTracking();
|
||||
|
||||
const pollInterval = 2000;
|
||||
aprsPollTimer = setInterval(async () => {
|
||||
@@ -9459,31 +9884,12 @@
|
||||
const response = await fetch(`/controller/agents/${aprsCurrentAgent}/aprs/data`);
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
const result = data.result || data;
|
||||
const stations = result.data || [];
|
||||
|
||||
// Process new stations
|
||||
if (stations.length > lastAprsStationCount) {
|
||||
const newStations = stations.slice(lastAprsStationCount);
|
||||
newStations.forEach(station => {
|
||||
aprsPacketCount++;
|
||||
document.getElementById('aprsPacketCount').textContent = aprsPacketCount;
|
||||
document.getElementById('aprsStripPackets').textContent = aprsPacketCount;
|
||||
const dot = document.getElementById('aprsStripDot');
|
||||
if (dot && !dot.classList.contains('tracking')) {
|
||||
updateAprsStatus('tracking');
|
||||
}
|
||||
// Convert to expected packet format
|
||||
const packet = {
|
||||
type: 'aprs',
|
||||
...station,
|
||||
agent_name: result.agent_name || 'Remote Agent'
|
||||
};
|
||||
processAprsPacket(packet);
|
||||
});
|
||||
lastAprsStationCount = stations.length;
|
||||
}
|
||||
const payload = await response.json();
|
||||
const stations = extractAprsStationsFromPayload(payload);
|
||||
const agentName = payload.agent_name ||
|
||||
(payload.data && payload.data.agent_name) ||
|
||||
'Remote Agent';
|
||||
processAprsAgentStations(stations, agentName);
|
||||
} catch (err) {
|
||||
console.error('APRS polling error:', err);
|
||||
}
|
||||
@@ -9597,7 +10003,7 @@
|
||||
}
|
||||
|
||||
// Update map if position data
|
||||
if (packet.lat && packet.lon && aprsMap) {
|
||||
if (aprsHasValidCoordinates(packet.lat, packet.lon) && aprsMap) {
|
||||
updateAprsMarker(packet);
|
||||
}
|
||||
|
||||
@@ -9642,22 +10048,27 @@
|
||||
|
||||
function updateAprsMarker(packet) {
|
||||
const callsign = packet.callsign;
|
||||
const lat = Number(packet.lat);
|
||||
const lon = Number(packet.lon);
|
||||
if (!aprsHasValidCoordinates(lat, lon)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate distance if user location available
|
||||
let distStr = '';
|
||||
if (aprsUserLocation.lat && aprsUserLocation.lon) {
|
||||
const dist = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, packet.lat, packet.lon);
|
||||
if (aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon)) {
|
||||
const dist = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, lat, lon);
|
||||
distStr = `Distance: ${dist.toFixed(1)} mi<br>`;
|
||||
}
|
||||
|
||||
if (aprsMarkers[callsign]) {
|
||||
// Update existing marker position and popup
|
||||
aprsMarkers[callsign].setLatLng([packet.lat, packet.lon]);
|
||||
aprsMarkers[callsign].setLatLng([lat, lon]);
|
||||
aprsMarkers[callsign].setIcon(buildAprsMarkerIcon(packet));
|
||||
aprsMarkers[callsign].setPopupContent(`
|
||||
<div style="font-family: monospace;">
|
||||
<strong>${callsign}</strong><br>
|
||||
Position: ${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}<br>
|
||||
Position: ${lat.toFixed(4)}, ${lon.toFixed(4)}<br>
|
||||
${distStr}
|
||||
${packet.altitude ? `Altitude: ${packet.altitude} ft<br>` : ''}
|
||||
${packet.speed ? `Speed: ${packet.speed} kts<br>` : ''}
|
||||
@@ -9671,12 +10082,12 @@
|
||||
document.getElementById('aprsStationCount').textContent = aprsStationCount;
|
||||
document.getElementById('aprsStripStations').textContent = aprsStationCount;
|
||||
|
||||
const marker = L.marker([packet.lat, packet.lon], { icon: buildAprsMarkerIcon(packet) }).addTo(aprsMap);
|
||||
const marker = L.marker([lat, lon], { icon: buildAprsMarkerIcon(packet) }).addTo(aprsMap);
|
||||
|
||||
marker.bindPopup(`
|
||||
<div style="font-family: monospace;">
|
||||
<strong>${callsign}</strong><br>
|
||||
Position: ${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}<br>
|
||||
Position: ${lat.toFixed(4)}, ${lon.toFixed(4)}<br>
|
||||
${distStr}
|
||||
${packet.altitude ? `Altitude: ${packet.altitude} ft<br>` : ''}
|
||||
${packet.speed ? `Speed: ${packet.speed} kts<br>` : ''}
|
||||
@@ -9700,9 +10111,11 @@
|
||||
|
||||
// Calculate distance if user location available
|
||||
let distance = null;
|
||||
const hasPos = packet.lat && packet.lon;
|
||||
if (hasPos && aprsUserLocation.lat && aprsUserLocation.lon) {
|
||||
distance = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, packet.lat, packet.lon);
|
||||
const hasPos = aprsHasValidCoordinates(packet.lat, packet.lon);
|
||||
const lat = hasPos ? Number(packet.lat) : null;
|
||||
const lon = hasPos ? Number(packet.lon) : null;
|
||||
if (hasPos && aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon)) {
|
||||
distance = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, lat, lon);
|
||||
}
|
||||
|
||||
// Check if station already exists
|
||||
@@ -9713,8 +10126,8 @@
|
||||
const msg = {
|
||||
callsign: callsign,
|
||||
packet_type: packet.packet_type || 'unknown',
|
||||
latitude: packet.lat,
|
||||
longitude: packet.lon,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
altitude: packet.altitude,
|
||||
speed: packet.speed,
|
||||
course: packet.course,
|
||||
@@ -9732,8 +10145,8 @@
|
||||
|
||||
// Store position for distance updates
|
||||
if (hasPos) {
|
||||
newCard.dataset.lat = packet.lat;
|
||||
newCard.dataset.lon = packet.lon;
|
||||
newCard.dataset.lat = lat;
|
||||
newCard.dataset.lon = lon;
|
||||
}
|
||||
|
||||
// Add click handler to focus map
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<!-- MORSE CODE MODE -->
|
||||
<div id="morseMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>CW/Morse Decoder</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||
Decode CW (continuous wave) Morse code from amateur radio HF bands using USB demodulation
|
||||
and Goertzel tone detection.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Frequency</h3>
|
||||
<div class="form-group">
|
||||
<label>Frequency (MHz)</label>
|
||||
<input type="number" id="morseFrequency" value="14.060" step="0.001" min="1" max="30">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Band Presets</label>
|
||||
<div class="morse-presets" style="display: flex; flex-wrap: wrap; gap: 4px;">
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(3.560)">80m</button>
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(7.030)">40m</button>
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(10.116)">30m</button>
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(14.060)">20m</button>
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(18.080)">17m</button>
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(21.060)">15m</button>
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(24.910)">12m</button>
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(28.060)">10m</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Settings</h3>
|
||||
<div class="form-group">
|
||||
<label>Gain (dB)</label>
|
||||
<input type="number" id="morseGain" value="40" step="1" min="0" max="50">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>PPM Correction</label>
|
||||
<input type="number" id="morsePPM" value="0" step="1" min="-100" max="100">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>CW Settings</h3>
|
||||
<div class="form-group">
|
||||
<label>Tone Frequency: <span id="morseToneFreqLabel">700</span> Hz</label>
|
||||
<input type="range" id="morseToneFreq" value="700" min="300" max="1200" step="10"
|
||||
oninput="document.getElementById('morseToneFreqLabel').textContent = this.value">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Speed: <span id="morseWpmLabel">15</span> WPM</label>
|
||||
<input type="range" id="morseWpm" value="15" min="5" max="50" step="1"
|
||||
oninput="document.getElementById('morseWpmLabel').textContent = this.value">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Morse Reference -->
|
||||
<div class="section">
|
||||
<h3 style="cursor: pointer;" onclick="this.parentElement.querySelector('.morse-ref-grid').classList.toggle('collapsed')">
|
||||
Morse Reference <span style="font-size: 10px; color: var(--text-dim);">(click to toggle)</span>
|
||||
</h3>
|
||||
<div class="morse-ref-grid collapsed" style="font-family: var(--font-mono); font-size: 10px; line-height: 1.8; columns: 2; column-gap: 12px; color: var(--text-dim);">
|
||||
<div>A .-</div><div>B -...</div><div>C -.-.</div><div>D -..</div>
|
||||
<div>E .</div><div>F ..-.</div><div>G --.</div><div>H ....</div>
|
||||
<div>I ..</div><div>J .---</div><div>K -.-</div><div>L .-..</div>
|
||||
<div>M --</div><div>N -.</div><div>O ---</div><div>P .--.</div>
|
||||
<div>Q --.-</div><div>R .-.</div><div>S ...</div><div>T -</div>
|
||||
<div>U ..-</div><div>V ...-</div><div>W .--</div><div>X -..-</div>
|
||||
<div>Y -.--</div><div>Z --..</div>
|
||||
<div style="margin-top: 4px; border-top: 1px solid var(--border-color); padding-top: 4px;">0 -----</div>
|
||||
<div style="margin-top: 4px; border-top: 1px solid var(--border-color); padding-top: 4px;">1 .----</div>
|
||||
<div>2 ..---</div><div>3 ...--</div><div>4 ....-</div>
|
||||
<div>5 .....</div><div>6 -....</div><div>7 --...</div>
|
||||
<div>8 ---..</div><div>9 ----.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="section">
|
||||
<div class="morse-status" style="display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim);">
|
||||
<span id="morseStatusIndicator" class="status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim);"></span>
|
||||
<span id="morseStatusText">Standby</span>
|
||||
<span style="margin-left: auto;" id="morseCharCount">0 chars</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HF Antenna Note -->
|
||||
<div class="section">
|
||||
<p class="info-text" style="font-size: 11px; color: #ffaa00; line-height: 1.5;">
|
||||
CW operates on HF bands (1-30 MHz). Requires an HF-capable SDR with direct sampling
|
||||
or an upconverter, plus an appropriate HF antenna (dipole, end-fed, or random wire).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="morseStartBtn" onclick="MorseMode.start()">Start Decoder</button>
|
||||
<button class="stop-btn" id="morseStopBtn" onclick="MorseMode.stop()" style="display: none;">Stop Decoder</button>
|
||||
</div>
|
||||
@@ -169,20 +169,32 @@
|
||||
.catch(err => alert('Error: ' + err.message));
|
||||
}
|
||||
|
||||
function stopVdl2Mode() {
|
||||
fetch('/vdl2/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
document.getElementById('startVdl2Btn').style.display = 'block';
|
||||
document.getElementById('stopVdl2Btn').style.display = 'none';
|
||||
document.getElementById('vdl2StatusText').textContent = 'Standby';
|
||||
document.getElementById('vdl2StatusText').style.color = 'var(--accent-yellow)';
|
||||
if (vdl2MainEventSource) {
|
||||
vdl2MainEventSource.close();
|
||||
vdl2MainEventSource = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
function stopVdl2Mode() {
|
||||
fetch('/vdl2/stop', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source: 'vdl2_mode' })
|
||||
})
|
||||
.then(async (r) => {
|
||||
const text = await r.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
if (!r.ok || (data.status !== 'stopped' && data.status !== 'success')) {
|
||||
throw new Error(data.message || `HTTP ${r.status}`);
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.then(() => {
|
||||
document.getElementById('startVdl2Btn').style.display = 'block';
|
||||
document.getElementById('stopVdl2Btn').style.display = 'none';
|
||||
document.getElementById('vdl2StatusText').textContent = 'Standby';
|
||||
document.getElementById('vdl2StatusText').style.color = 'var(--accent-yellow)';
|
||||
if (vdl2MainEventSource) {
|
||||
vdl2MainEventSource.close();
|
||||
vdl2MainEventSource = null;
|
||||
}
|
||||
})
|
||||
.catch(err => alert('Failed to stop VDL2: ' + err.message));
|
||||
}
|
||||
|
||||
function startVdl2MainSSE() {
|
||||
if (vdl2MainEventSource) vdl2MainEventSource.close();
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<!-- WEFAX MODE -->
|
||||
<div id="wefaxMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>WeFax Decoder</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||
Decode HF weather fax (radiofax) from maritime and aviation weather services.
|
||||
Stations broadcast weather charts on fixed schedules via HF radio.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Station</h3>
|
||||
<div class="form-group">
|
||||
<label>Station</label>
|
||||
<select id="wefaxStation" onchange="WeFax.onStationChange()">
|
||||
<option value="">Select a station...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Frequency (kHz)</label>
|
||||
<select id="wefaxFrequency">
|
||||
<option value="">Select station first</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Settings</h3>
|
||||
<div class="form-group">
|
||||
<label>IOC (Index of Cooperation)</label>
|
||||
<select id="wefaxIOC">
|
||||
<option value="576" selected>576 (Standard)</option>
|
||||
<option value="288">288 (Half)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>LPM (Lines Per Minute)</label>
|
||||
<select id="wefaxLPM">
|
||||
<option value="120" selected>120 (Standard)</option>
|
||||
<option value="60">60 (Slow)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Gain (dB)</label>
|
||||
<input type="number" id="wefaxGain" value="40" step="1" min="0" max="50">
|
||||
</div>
|
||||
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="checkbox" id="wefaxDirectSampling" checked>
|
||||
<label for="wefaxDirectSampling" style="margin: 0; cursor: pointer;">Direct Sampling (Q-branch, required for HF)</label>
|
||||
</div>
|
||||
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="checkbox" id="wefaxAutoUsbAlign" checked>
|
||||
<label for="wefaxAutoUsbAlign" style="margin: 0; cursor: pointer;">Auto USB align listed carrier frequencies (-1.9 kHz)</label>
|
||||
</div>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-top: -4px;">
|
||||
Disable this if your source already provides USB dial frequencies.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Auto Capture</h3>
|
||||
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="checkbox" id="wefaxSidebarAutoSchedule"
|
||||
onchange="WeFax.toggleScheduler(this)">
|
||||
<label for="wefaxSidebarAutoSchedule" style="margin: 0; cursor: pointer;">Auto-capture scheduled broadcasts</label>
|
||||
</div>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-top: 4px;">
|
||||
Automatically decode at scheduled broadcast times.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide -->
|
||||
<div class="section">
|
||||
<h3>HF Antenna Guide</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
<p style="margin-bottom: 8px; color: #ffaa00; font-weight: 600;">
|
||||
HF band (2–30 MHz) — requires HF antenna + direct sampling SDR
|
||||
</p>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: #ffaa00; font-size: 12px;">Requirements</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">SDR:</strong> RTL-SDR (direct sampling), HackRF, LimeSDR, Airspy, or SDRPlay</li>
|
||||
<li><strong style="color: var(--text-primary);">Antenna:</strong> Long wire (10m+), random wire, or dipole for target band</li>
|
||||
<li><strong style="color: var(--text-primary);">Mode:</strong> USB (Upper Sideband) demodulation</li>
|
||||
<li><strong style="color: var(--text-primary);">Signals:</strong> Moderate — HF propagation varies by time of day</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||
<strong style="color: #ffaa00; font-size: 12px;">Quick Reference</strong>
|
||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Protocol</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">AM Facsimile (ITU-T T.4)</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Carrier</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">1900 Hz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Deviation</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">±400 Hz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Black / White</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">1500 / 2300 Hz</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Start / Stop tone</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">300 / 450 Hz</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Resources</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<a href="https://www.weather.gov/marine/radiofax_charts" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
NWS Radiofax Charts
|
||||
</a>
|
||||
<a href="https://www.nws.noaa.gov/os/marine/rfax.pdf" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
NOAA Radiofax Schedule (PDF)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,6 +67,7 @@
|
||||
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
|
||||
{{ mode_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.4"/><path d="M2 21h20" opacity="0.2"/></svg>') }}
|
||||
{{ mode_item('morse', 'Morse', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,6 +103,7 @@
|
||||
{% endif %}
|
||||
{{ mode_item('sstv', 'ISS SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }}
|
||||
{{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||
{{ mode_item('wefax', 'WeFax', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>') }}
|
||||
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||
{{ mode_item('spaceweather', 'Space Weather', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>') }}
|
||||
</div>
|
||||
@@ -200,6 +202,7 @@
|
||||
{{ mobile_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
|
||||
{{ mobile_item('morse', 'Morse', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>') }}
|
||||
{# Tracking #}
|
||||
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
|
||||
{{ mobile_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
|
||||
@@ -213,6 +216,7 @@
|
||||
{% endif %}
|
||||
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
||||
{{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||
{{ mobile_item('wefax', 'WeFax', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>') }}
|
||||
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
||||
{{ mobile_item('spaceweather', 'SpaceWx', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg>') }}
|
||||
{# Wireless #}
|
||||
|
||||
@@ -323,6 +323,17 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<div class="settings-label">
|
||||
<span class="settings-label-text">Military Aircraft</span>
|
||||
<span class="settings-label-desc">Speak when military aircraft are detected</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="voiceCfgAdsbMilitary" checked onchange="saveVoiceAlertConfig()">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<div class="settings-label">
|
||||
<span class="settings-label-text">Emergency Squawks</span>
|
||||
|
||||
@@ -971,7 +971,12 @@
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Position update error:', err);
|
||||
const transient = (typeof window.isTransientOrOffline === 'function' && window.isTransientOrOffline(err)) ||
|
||||
(typeof navigator !== 'undefined' && navigator.onLine === false) ||
|
||||
/failed to fetch|network io suspended|networkerror|timeout/i.test(String((err && err.message) || err || ''));
|
||||
if (!transient) {
|
||||
console.error('Position update error:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""APRS packet parser regression tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from routes.aprs import parse_aprs_packet
|
||||
|
||||
|
||||
_BASE_PACKET = "N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"line",
|
||||
[
|
||||
_BASE_PACKET,
|
||||
f"[0.4] {_BASE_PACKET}",
|
||||
f"[0L] {_BASE_PACKET}",
|
||||
f"AFSK1200: {_BASE_PACKET}",
|
||||
f"AFSK1200: [0L] {_BASE_PACKET}",
|
||||
],
|
||||
)
|
||||
def test_parse_aprs_packet_accepts_decoder_prefix_variants(line: str) -> None:
|
||||
packet = parse_aprs_packet(line)
|
||||
assert packet is not None
|
||||
assert packet["callsign"] == "N0CALL-9"
|
||||
assert packet["type"] == "aprs"
|
||||
|
||||
|
||||
def test_parse_aprs_packet_accepts_callsign_with_tactical_suffix() -> None:
|
||||
packet = parse_aprs_packet("CALL/1>APRS:!4903.50N/07201.75W-Test")
|
||||
assert packet is not None
|
||||
assert packet["callsign"] == "CALL/1"
|
||||
assert packet["lat"] == pytest.approx(49.058333, rel=0, abs=1e-6)
|
||||
assert packet["lon"] == pytest.approx(-72.029167, rel=0, abs=1e-6)
|
||||
|
||||
|
||||
def test_parse_aprs_packet_handles_ambiguous_uncompressed_position() -> None:
|
||||
packet = parse_aprs_packet("KJ7ABC-7>APRS,WIDE1-1:!4903. N/07201. W-Test")
|
||||
assert packet is not None
|
||||
assert packet["packet_type"] == "position"
|
||||
assert packet["lat"] == pytest.approx(49.05, rel=0, abs=1e-6)
|
||||
assert packet["lon"] == pytest.approx(-72.016667, rel=0, abs=1e-6)
|
||||
|
||||
|
||||
def test_parse_aprs_packet_handles_no_decimal_position_variant() -> None:
|
||||
packet = parse_aprs_packet("KJ7ABC-7>APRS,WIDE1-1:!4903N/07201W-Test")
|
||||
assert packet is not None
|
||||
assert packet["packet_type"] == "position"
|
||||
assert packet["lat"] == pytest.approx(49.05, rel=0, abs=1e-6)
|
||||
assert packet["lon"] == pytest.approx(-72.016667, rel=0, abs=1e-6)
|
||||
+239
-37
@@ -1,8 +1,8 @@
|
||||
"""Tests for DSC (Digital Selective Calling) utilities."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TestDSCParser:
|
||||
@@ -88,17 +88,15 @@ class TestDSCParser:
|
||||
assert get_distress_nature_text('invalid') == 'invalid'
|
||||
|
||||
def test_get_format_text(self):
|
||||
"""Test format code to text conversion."""
|
||||
"""Test format code to text conversion per ITU-R M.493."""
|
||||
from utils.dsc.parser import get_format_text
|
||||
|
||||
assert get_format_text(100) == 'DISTRESS'
|
||||
assert get_format_text(102) == 'ALL_SHIPS'
|
||||
assert get_format_text(106) == 'DISTRESS_ACK'
|
||||
assert get_format_text(108) == 'DISTRESS_RELAY'
|
||||
assert get_format_text(112) == 'INDIVIDUAL'
|
||||
assert get_format_text(116) == 'ROUTINE'
|
||||
assert get_format_text(118) == 'SAFETY'
|
||||
assert get_format_text(120) == 'URGENCY'
|
||||
assert get_format_text(114) == 'INDIVIDUAL_ACK'
|
||||
assert get_format_text(116) == 'GROUP'
|
||||
assert get_format_text(120) == 'DISTRESS'
|
||||
assert get_format_text(123) == 'ALL_SHIPS_URGENCY_SAFETY'
|
||||
|
||||
def test_get_format_text_unknown(self):
|
||||
"""Test format code returns unknown for invalid codes."""
|
||||
@@ -107,6 +105,15 @@ class TestDSCParser:
|
||||
result = get_format_text(999)
|
||||
assert 'UNKNOWN' in result
|
||||
|
||||
def test_get_format_text_removed_codes(self):
|
||||
"""Test that non-ITU format codes are no longer recognized."""
|
||||
from utils.dsc.parser import get_format_text
|
||||
|
||||
# These were previously defined but are not ITU-R M.493 specifiers
|
||||
for code in [100, 104, 106, 108, 110, 118]:
|
||||
result = get_format_text(code)
|
||||
assert 'UNKNOWN' in result
|
||||
|
||||
def test_get_telecommand_text(self):
|
||||
"""Test telecommand code to text conversion."""
|
||||
from utils.dsc.parser import get_telecommand_text
|
||||
@@ -124,14 +131,13 @@ class TestDSCParser:
|
||||
assert get_category_priority('DISTRESS') == 0
|
||||
assert get_category_priority('distress') == 0
|
||||
|
||||
# Urgency is lower
|
||||
assert get_category_priority('URGENCY') == 3
|
||||
# Urgency/safety
|
||||
assert get_category_priority('ALL_SHIPS_URGENCY_SAFETY') == 2
|
||||
|
||||
# Safety is lower still
|
||||
assert get_category_priority('SAFETY') == 4
|
||||
|
||||
# Routine is lowest
|
||||
assert get_category_priority('ROUTINE') == 5
|
||||
# Routine-level
|
||||
assert get_category_priority('ALL_SHIPS') == 5
|
||||
assert get_category_priority('GROUP') == 5
|
||||
assert get_category_priority('INDIVIDUAL') == 5
|
||||
|
||||
# Unknown gets default high number
|
||||
assert get_category_priority('UNKNOWN') == 10
|
||||
@@ -182,19 +188,20 @@ class TestDSCParser:
|
||||
assert classify_mmsi('812345678') == 'unknown'
|
||||
|
||||
def test_parse_dsc_message_distress(self):
|
||||
"""Test parsing a distress message."""
|
||||
"""Test parsing a distress message with ITU format 120."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 100,
|
||||
'format': 120,
|
||||
'source_mmsi': '232123456',
|
||||
'dest_mmsi': '000000000',
|
||||
'dest_mmsi': '002320001',
|
||||
'category': 'DISTRESS',
|
||||
'nature': 101,
|
||||
'position': {'lat': 51.5, 'lon': -0.1},
|
||||
'telecommand1': 100,
|
||||
'timestamp': '2025-01-15T12:00:00Z'
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': '120002032123456101100127',
|
||||
})
|
||||
|
||||
msg = parse_dsc_message(raw)
|
||||
@@ -210,26 +217,49 @@ class TestDSCParser:
|
||||
assert msg['is_critical'] is True
|
||||
assert msg['priority'] == 0
|
||||
|
||||
def test_parse_dsc_message_routine(self):
|
||||
"""Test parsing a routine message."""
|
||||
def test_parse_dsc_message_group(self):
|
||||
"""Test parsing a group call message."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 116,
|
||||
'source_mmsi': '366000001',
|
||||
'category': 'ROUTINE',
|
||||
'timestamp': '2025-01-15T12:00:00Z'
|
||||
'dest_mmsi': '023200001',
|
||||
'category': 'GROUP',
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': '116023200001366000001117',
|
||||
})
|
||||
|
||||
msg = parse_dsc_message(raw)
|
||||
|
||||
assert msg is not None
|
||||
assert msg['category'] == 'ROUTINE'
|
||||
assert msg['category'] == 'GROUP'
|
||||
assert msg['source_country'] == 'USA'
|
||||
assert msg['is_critical'] is False
|
||||
assert msg['priority'] == 5
|
||||
|
||||
def test_parse_dsc_message_individual(self):
|
||||
"""Test parsing an individual call message."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 112,
|
||||
'source_mmsi': '366000001',
|
||||
'dest_mmsi': '232123456',
|
||||
'category': 'INDIVIDUAL',
|
||||
'telecommand1': 100,
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': '112232123456366000001100122',
|
||||
})
|
||||
|
||||
msg = parse_dsc_message(raw)
|
||||
|
||||
assert msg is not None
|
||||
assert msg['category'] == 'INDIVIDUAL'
|
||||
assert msg['is_critical'] is False
|
||||
|
||||
def test_parse_dsc_message_invalid_json(self):
|
||||
"""Test parsing rejects invalid JSON."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
@@ -262,6 +292,171 @@ class TestDSCParser:
|
||||
assert parse_dsc_message(None) is None
|
||||
assert parse_dsc_message(' ') is None
|
||||
|
||||
def test_parse_dsc_message_rejects_non_itu_format(self):
|
||||
"""Test parser rejects records with non-ITU format specifier."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
for bad_format in [100, 104, 106, 108, 110, 118, 999]:
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': bad_format,
|
||||
'source_mmsi': '232123456',
|
||||
'category': 'ROUTINE',
|
||||
'raw': '120232123456100127',
|
||||
})
|
||||
assert parse_dsc_message(raw) is None, f"Format {bad_format} should be rejected"
|
||||
|
||||
def test_parse_dsc_message_rejects_telecommand_out_of_range(self):
|
||||
"""Test parser rejects records with telecommand out of 100-127 range."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 120,
|
||||
'source_mmsi': '232123456',
|
||||
'dest_mmsi': '002320001',
|
||||
'category': 'DISTRESS',
|
||||
'telecommand1': 200,
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': '120002032123456200127',
|
||||
})
|
||||
assert parse_dsc_message(raw) is None
|
||||
|
||||
def test_parse_dsc_message_accepts_zero_telecommand(self):
|
||||
"""Test parser does not drop telecommand with value 100 (truthiness fix)."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 112,
|
||||
'source_mmsi': '232123456',
|
||||
'dest_mmsi': '366000001',
|
||||
'category': 'INDIVIDUAL',
|
||||
'telecommand1': 100,
|
||||
'telecommand2': 100,
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': '112366000001232123456100100122',
|
||||
})
|
||||
|
||||
msg = parse_dsc_message(raw)
|
||||
assert msg is not None
|
||||
assert msg['telecommand1'] == 100
|
||||
assert msg['telecommand2'] == 100
|
||||
|
||||
def test_parse_dsc_message_validates_raw_field(self):
|
||||
"""Test parser validates raw field structure."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
# Non-digit raw field
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 120,
|
||||
'source_mmsi': '232123456',
|
||||
'category': 'DISTRESS',
|
||||
'raw': '12abc',
|
||||
})
|
||||
assert parse_dsc_message(raw) is None
|
||||
|
||||
# Raw field length not divisible by 3
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 120,
|
||||
'source_mmsi': '232123456',
|
||||
'category': 'DISTRESS',
|
||||
'raw': '1201',
|
||||
})
|
||||
assert parse_dsc_message(raw) is None
|
||||
|
||||
# Raw field with non-EOS last token
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 120,
|
||||
'source_mmsi': '232123456',
|
||||
'category': 'DISTRESS',
|
||||
'raw': '120100',
|
||||
})
|
||||
assert parse_dsc_message(raw) is None
|
||||
|
||||
def test_parse_dsc_message_accepts_valid_eos_in_raw(self):
|
||||
"""Test parser accepts all three valid EOS values in raw field."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
for eos in [117, 122, 127]:
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 120,
|
||||
'source_mmsi': '232123456',
|
||||
'dest_mmsi': '002320001',
|
||||
'category': 'DISTRESS',
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': f'120002032123456{eos:03d}',
|
||||
})
|
||||
msg = parse_dsc_message(raw)
|
||||
assert msg is not None, f"EOS {eos} should be accepted"
|
||||
|
||||
def test_parse_dsc_message_rejects_invalid_mmsi(self):
|
||||
"""Test parser rejects invalid MMSI values."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
# All-zeros MMSI
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 120,
|
||||
'source_mmsi': '000000000',
|
||||
'category': 'DISTRESS',
|
||||
'raw': '120000000000127',
|
||||
})
|
||||
assert parse_dsc_message(raw) is None
|
||||
|
||||
# Short MMSI
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 120,
|
||||
'source_mmsi': '12345',
|
||||
'category': 'DISTRESS',
|
||||
'raw': '120127',
|
||||
})
|
||||
assert parse_dsc_message(raw) is None
|
||||
|
||||
def test_parse_dsc_message_nature_zero_not_dropped(self):
|
||||
"""Test that nature code 0 is not dropped by truthiness check."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 120,
|
||||
'source_mmsi': '232123456',
|
||||
'dest_mmsi': '002320001',
|
||||
'category': 'DISTRESS',
|
||||
'nature': 0,
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': '120002032123456000127',
|
||||
})
|
||||
|
||||
msg = parse_dsc_message(raw)
|
||||
assert msg is not None
|
||||
assert msg['nature_code'] == 0
|
||||
|
||||
def test_parse_dsc_message_channel_zero_not_dropped(self):
|
||||
"""Test that channel value 0 is not dropped by truthiness check."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 112,
|
||||
'source_mmsi': '232123456',
|
||||
'dest_mmsi': '366000001',
|
||||
'category': 'INDIVIDUAL',
|
||||
'channel': 0,
|
||||
'telecommand1': 100,
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': '112366000001232123456100122',
|
||||
})
|
||||
|
||||
msg = parse_dsc_message(raw)
|
||||
assert msg is not None
|
||||
assert msg['channel'] == 0
|
||||
|
||||
def test_format_dsc_for_display(self):
|
||||
"""Test message formatting for display."""
|
||||
from utils.dsc.parser import format_dsc_for_display
|
||||
@@ -413,17 +608,24 @@ class TestDSCConstants:
|
||||
"""Tests for DSC constants."""
|
||||
|
||||
def test_format_codes_completeness(self):
|
||||
"""Test that all standard format codes are defined."""
|
||||
"""Test that all ITU-R M.493 format specifiers are defined."""
|
||||
from utils.dsc.constants import FORMAT_CODES
|
||||
|
||||
# ITU-R M.493 format codes
|
||||
assert 100 in FORMAT_CODES # DISTRESS
|
||||
assert 102 in FORMAT_CODES # ALL_SHIPS
|
||||
assert 106 in FORMAT_CODES # DISTRESS_ACK
|
||||
assert 112 in FORMAT_CODES # INDIVIDUAL
|
||||
assert 116 in FORMAT_CODES # ROUTINE
|
||||
assert 118 in FORMAT_CODES # SAFETY
|
||||
assert 120 in FORMAT_CODES # URGENCY
|
||||
# ITU-R M.493 format specifiers (and only these)
|
||||
expected_keys = {102, 112, 114, 116, 120, 123}
|
||||
assert set(FORMAT_CODES.keys()) == expected_keys
|
||||
|
||||
def test_valid_format_specifiers_set(self):
|
||||
"""Test VALID_FORMAT_SPECIFIERS matches FORMAT_CODES keys."""
|
||||
from utils.dsc.constants import FORMAT_CODES, VALID_FORMAT_SPECIFIERS
|
||||
|
||||
assert set(FORMAT_CODES.keys()) == VALID_FORMAT_SPECIFIERS
|
||||
|
||||
def test_valid_eos_symbols(self):
|
||||
"""Test VALID_EOS contains the three ITU-defined EOS symbols."""
|
||||
from utils.dsc.constants import VALID_EOS
|
||||
|
||||
assert {117, 122, 127} == VALID_EOS
|
||||
|
||||
def test_distress_nature_codes_completeness(self):
|
||||
"""Test that all distress nature codes are defined."""
|
||||
@@ -458,13 +660,13 @@ class TestDSCConstants:
|
||||
assert VHF_CHANNELS[70] == 156.525
|
||||
|
||||
def test_dsc_modulation_parameters(self):
|
||||
"""Test DSC modulation constants."""
|
||||
"""Test DSC modulation constants per ITU-R M.493."""
|
||||
from utils.dsc.constants import (
|
||||
DSC_BAUD_RATE,
|
||||
DSC_MARK_FREQ,
|
||||
DSC_SPACE_FREQ,
|
||||
)
|
||||
|
||||
assert DSC_BAUD_RATE == 100
|
||||
assert DSC_MARK_FREQ == 1800
|
||||
assert DSC_SPACE_FREQ == 1200
|
||||
assert DSC_BAUD_RATE == 1200
|
||||
assert DSC_MARK_FREQ == 2100
|
||||
assert DSC_SPACE_FREQ == 1300
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tests for GPS route behavior and gps client callback management."""
|
||||
|
||||
from routes import gps as gps_routes
|
||||
from utils.gps import GPSDClient
|
||||
|
||||
|
||||
def test_gpsd_client_add_callback_deduplicates():
|
||||
"""Adding the same position callback twice should only register once."""
|
||||
client = GPSDClient()
|
||||
|
||||
def callback(_position):
|
||||
return None
|
||||
|
||||
client.add_callback(callback)
|
||||
client.add_callback(callback)
|
||||
|
||||
assert client._callbacks.count(callback) == 1
|
||||
|
||||
|
||||
def test_gpsd_client_add_sky_callback_deduplicates():
|
||||
"""Adding the same sky callback twice should only register once."""
|
||||
client = GPSDClient()
|
||||
|
||||
def callback(_sky):
|
||||
return None
|
||||
|
||||
client.add_sky_callback(callback)
|
||||
client.add_sky_callback(callback)
|
||||
|
||||
assert client._sky_callbacks.count(callback) == 1
|
||||
|
||||
|
||||
def test_auto_connect_attaches_callbacks_when_reader_already_running(client, monkeypatch):
|
||||
"""Auto-connect should re-attach stream callbacks for an already-running reader."""
|
||||
|
||||
class FakeReader:
|
||||
is_running = True
|
||||
position = None
|
||||
sky = None
|
||||
|
||||
def __init__(self):
|
||||
self.position_callbacks = []
|
||||
self.sky_callbacks = []
|
||||
|
||||
def add_callback(self, callback):
|
||||
self.position_callbacks.append(callback)
|
||||
|
||||
def add_sky_callback(self, callback):
|
||||
self.sky_callbacks.append(callback)
|
||||
|
||||
reader = FakeReader()
|
||||
monkeypatch.setattr(gps_routes, 'get_gps_reader', lambda: reader)
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
|
||||
response = client.post('/gps/auto-connect')
|
||||
payload = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert payload['status'] == 'connected'
|
||||
assert reader.position_callbacks == [gps_routes._position_callback]
|
||||
assert reader.sky_callbacks == [gps_routes._sky_callback]
|
||||
|
||||
|
||||
def test_satellites_returns_waiting_when_reader_not_running(client, monkeypatch):
|
||||
"""Satellite endpoint should return a non-error waiting state when reader is down."""
|
||||
monkeypatch.setattr(gps_routes, 'get_gps_reader', lambda: None)
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
|
||||
response = client.get('/gps/satellites')
|
||||
payload = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert payload['status'] == 'waiting'
|
||||
assert payload['running'] is False
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Tests for Morse code decoder (utils/morse.py) and routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import queue
|
||||
import struct
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from utils.morse import (
|
||||
CHAR_TO_MORSE,
|
||||
MORSE_TABLE,
|
||||
GoertzelFilter,
|
||||
MorseDecoder,
|
||||
morse_decoder_thread,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _login_session(client) -> None:
|
||||
"""Mark the Flask test session as authenticated."""
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
sess['username'] = 'test'
|
||||
sess['role'] = 'admin'
|
||||
|
||||
|
||||
def generate_tone(freq: float, duration: float, sample_rate: int = 8000, amplitude: float = 0.8) -> bytes:
|
||||
"""Generate a pure sine wave as 16-bit LE PCM bytes."""
|
||||
n_samples = int(sample_rate * duration)
|
||||
samples = []
|
||||
for i in range(n_samples):
|
||||
t = i / sample_rate
|
||||
val = int(amplitude * 32767 * math.sin(2 * math.pi * freq * t))
|
||||
samples.append(max(-32768, min(32767, val)))
|
||||
return struct.pack(f'<{len(samples)}h', *samples)
|
||||
|
||||
|
||||
def generate_silence(duration: float, sample_rate: int = 8000) -> bytes:
|
||||
"""Generate silence as 16-bit LE PCM bytes."""
|
||||
n_samples = int(sample_rate * duration)
|
||||
return b'\x00\x00' * n_samples
|
||||
|
||||
|
||||
def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sample_rate: int = 8000) -> bytes:
|
||||
"""Generate PCM audio for a Morse-encoded string."""
|
||||
dit_dur = 1.2 / wpm
|
||||
dah_dur = 3 * dit_dur
|
||||
element_gap = dit_dur
|
||||
char_gap = 3 * dit_dur
|
||||
word_gap = 7 * dit_dur
|
||||
|
||||
audio = b''
|
||||
words = text.upper().split()
|
||||
for wi, word in enumerate(words):
|
||||
for ci, char in enumerate(word):
|
||||
morse = CHAR_TO_MORSE.get(char)
|
||||
if morse is None:
|
||||
continue
|
||||
for ei, element in enumerate(morse):
|
||||
if element == '.':
|
||||
audio += generate_tone(tone_freq, dit_dur, sample_rate)
|
||||
elif element == '-':
|
||||
audio += generate_tone(tone_freq, dah_dur, sample_rate)
|
||||
if ei < len(morse) - 1:
|
||||
audio += generate_silence(element_gap, sample_rate)
|
||||
if ci < len(word) - 1:
|
||||
audio += generate_silence(char_gap, sample_rate)
|
||||
if wi < len(words) - 1:
|
||||
audio += generate_silence(word_gap, sample_rate)
|
||||
|
||||
# Add some leading/trailing silence for threshold settling
|
||||
silence = generate_silence(0.3, sample_rate)
|
||||
return silence + audio + silence
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MORSE_TABLE tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMorseTable:
|
||||
def test_all_26_letters_present(self):
|
||||
chars = set(MORSE_TABLE.values())
|
||||
for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
|
||||
assert letter in chars, f"Missing letter: {letter}"
|
||||
|
||||
def test_all_10_digits_present(self):
|
||||
chars = set(MORSE_TABLE.values())
|
||||
for digit in '0123456789':
|
||||
assert digit in chars, f"Missing digit: {digit}"
|
||||
|
||||
def test_reverse_lookup_consistent(self):
|
||||
for morse, char in MORSE_TABLE.items():
|
||||
if char in CHAR_TO_MORSE:
|
||||
assert CHAR_TO_MORSE[char] == morse
|
||||
|
||||
def test_no_duplicate_morse_codes(self):
|
||||
"""Each morse pattern should map to exactly one character."""
|
||||
assert len(MORSE_TABLE) == len(set(MORSE_TABLE.keys()))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GoertzelFilter tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGoertzelFilter:
|
||||
def test_detects_target_frequency(self):
|
||||
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
|
||||
# Generate 700 Hz tone
|
||||
samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(160)]
|
||||
mag = gf.magnitude(samples)
|
||||
assert mag > 10.0, f"Expected high magnitude for target freq, got {mag}"
|
||||
|
||||
def test_rejects_off_frequency(self):
|
||||
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
|
||||
# Generate 1500 Hz tone (well off target)
|
||||
samples = [0.8 * math.sin(2 * math.pi * 1500 * i / 8000) for i in range(160)]
|
||||
mag_off = gf.magnitude(samples)
|
||||
|
||||
# Compare with on-target
|
||||
samples_on = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(160)]
|
||||
mag_on = gf.magnitude(samples_on)
|
||||
|
||||
assert mag_on > mag_off * 3, "Target freq should be significantly stronger than off-freq"
|
||||
|
||||
def test_silence_returns_near_zero(self):
|
||||
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
|
||||
samples = [0.0] * 160
|
||||
mag = gf.magnitude(samples)
|
||||
assert mag < 0.01, f"Expected near-zero for silence, got {mag}"
|
||||
|
||||
def test_different_block_sizes(self):
|
||||
for block_size in [80, 160, 320]:
|
||||
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=block_size)
|
||||
samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(block_size)]
|
||||
mag = gf.magnitude(samples)
|
||||
assert mag > 5.0, f"Should detect tone with block_size={block_size}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MorseDecoder tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMorseDecoder:
|
||||
def _make_decoder(self, wpm=15):
|
||||
"""Create decoder with pre-warmed threshold for testing."""
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=wpm)
|
||||
# Warm up noise floor with silence
|
||||
silence = generate_silence(0.5)
|
||||
decoder.process_block(silence)
|
||||
# Warm up signal peak with tone
|
||||
tone = generate_tone(700.0, 0.3)
|
||||
decoder.process_block(tone)
|
||||
# More silence to settle
|
||||
silence2 = generate_silence(0.5)
|
||||
decoder.process_block(silence2)
|
||||
# Reset state after warm-up
|
||||
decoder._tone_on = False
|
||||
decoder._current_symbol = ''
|
||||
decoder._tone_blocks = 0
|
||||
decoder._silence_blocks = 0
|
||||
return decoder
|
||||
|
||||
def test_dit_detection(self):
|
||||
"""A single dit should produce a '.' in the symbol buffer."""
|
||||
decoder = self._make_decoder()
|
||||
dit_dur = 1.2 / 15
|
||||
|
||||
# Send a tone burst (dit)
|
||||
tone = generate_tone(700.0, dit_dur)
|
||||
decoder.process_block(tone)
|
||||
|
||||
# Send silence to trigger end of tone
|
||||
silence = generate_silence(dit_dur * 2)
|
||||
decoder.process_block(silence)
|
||||
|
||||
# Symbol buffer should have a dot
|
||||
assert '.' in decoder._current_symbol, f"Expected '.' in symbol, got '{decoder._current_symbol}'"
|
||||
|
||||
def test_dah_detection(self):
|
||||
"""A longer tone should produce a '-' in the symbol buffer."""
|
||||
decoder = self._make_decoder()
|
||||
dah_dur = 3 * 1.2 / 15
|
||||
|
||||
tone = generate_tone(700.0, dah_dur)
|
||||
decoder.process_block(tone)
|
||||
|
||||
silence = generate_silence(dah_dur)
|
||||
decoder.process_block(silence)
|
||||
|
||||
assert '-' in decoder._current_symbol, f"Expected '-' in symbol, got '{decoder._current_symbol}'"
|
||||
|
||||
def test_decode_letter_e(self):
|
||||
"""E is a single dit - the simplest character."""
|
||||
decoder = self._make_decoder()
|
||||
audio = generate_morse_audio('E', wpm=15)
|
||||
events = decoder.process_block(audio)
|
||||
events.extend(decoder.flush())
|
||||
|
||||
chars = [e for e in events if e['type'] == 'morse_char']
|
||||
decoded = ''.join(e['char'] for e in chars)
|
||||
assert 'E' in decoded, f"Expected 'E' in decoded text, got '{decoded}'"
|
||||
|
||||
def test_decode_letter_t(self):
|
||||
"""T is a single dah."""
|
||||
decoder = self._make_decoder()
|
||||
audio = generate_morse_audio('T', wpm=15)
|
||||
events = decoder.process_block(audio)
|
||||
events.extend(decoder.flush())
|
||||
|
||||
chars = [e for e in events if e['type'] == 'morse_char']
|
||||
decoded = ''.join(e['char'] for e in chars)
|
||||
assert 'T' in decoded, f"Expected 'T' in decoded text, got '{decoded}'"
|
||||
|
||||
def test_word_space_detection(self):
|
||||
"""A long silence between words should produce decoded chars with a space."""
|
||||
decoder = self._make_decoder()
|
||||
dit_dur = 1.2 / 15
|
||||
# E = dit
|
||||
audio = generate_tone(700.0, dit_dur) + generate_silence(7 * dit_dur * 1.5)
|
||||
# T = dah
|
||||
audio += generate_tone(700.0, 3 * dit_dur) + generate_silence(3 * dit_dur)
|
||||
events = decoder.process_block(audio)
|
||||
events.extend(decoder.flush())
|
||||
|
||||
spaces = [e for e in events if e['type'] == 'morse_space']
|
||||
assert len(spaces) >= 1, "Expected at least one word space"
|
||||
|
||||
def test_scope_events_generated(self):
|
||||
"""Decoder should produce scope events for visualization."""
|
||||
audio = generate_morse_audio('SOS', wpm=15)
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
||||
|
||||
events = decoder.process_block(audio)
|
||||
|
||||
scope_events = [e for e in events if e['type'] == 'scope']
|
||||
assert len(scope_events) > 0, "Expected scope events"
|
||||
# Check scope event structure
|
||||
se = scope_events[0]
|
||||
assert 'amplitudes' in se
|
||||
assert 'threshold' in se
|
||||
assert 'tone_on' in se
|
||||
|
||||
def test_adaptive_threshold_adjusts(self):
|
||||
"""After processing audio, threshold should be non-zero."""
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
||||
|
||||
# Process some tone + silence
|
||||
audio = generate_tone(700.0, 0.3) + generate_silence(0.3)
|
||||
decoder.process_block(audio)
|
||||
|
||||
assert decoder._threshold > 0, "Threshold should adapt above zero"
|
||||
|
||||
def test_flush_emits_pending_char(self):
|
||||
"""flush() should emit any accumulated but not-yet-decoded symbol."""
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
||||
decoder._current_symbol = '.' # Manually set pending dit
|
||||
events = decoder.flush()
|
||||
assert len(events) == 1
|
||||
assert events[0]['type'] == 'morse_char'
|
||||
assert events[0]['char'] == 'E'
|
||||
|
||||
def test_flush_empty_returns_nothing(self):
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
||||
events = decoder.flush()
|
||||
assert events == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# morse_decoder_thread tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMorseDecoderThread:
|
||||
def test_thread_stops_on_event(self):
|
||||
"""Thread should exit when stop_event is set."""
|
||||
import io
|
||||
# Create a fake stdout that blocks until stop
|
||||
stop = threading.Event()
|
||||
q = queue.Queue(maxsize=100)
|
||||
|
||||
# Feed some audio then close
|
||||
audio = generate_morse_audio('E', wpm=15)
|
||||
fake_stdout = io.BytesIO(audio)
|
||||
|
||||
t = threading.Thread(
|
||||
target=morse_decoder_thread,
|
||||
args=(fake_stdout, q, stop),
|
||||
)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
t.join(timeout=5)
|
||||
assert not t.is_alive(), "Thread should finish after reading all data"
|
||||
|
||||
def test_thread_produces_events(self):
|
||||
"""Thread should push character events to the queue."""
|
||||
import io
|
||||
from unittest.mock import patch
|
||||
stop = threading.Event()
|
||||
q = queue.Queue(maxsize=1000)
|
||||
|
||||
# Generate audio with pre-warmed decoder in mind
|
||||
# The thread creates a fresh decoder, so generate lots of audio
|
||||
audio = generate_silence(0.5) + generate_morse_audio('SOS', wpm=10) + generate_silence(1.0)
|
||||
fake_stdout = io.BytesIO(audio)
|
||||
|
||||
# Patch SCOPE_INTERVAL to 0 so scope events aren't throttled in fast reads
|
||||
with patch('utils.morse.time') as mock_time:
|
||||
# Make monotonic() always return increasing values
|
||||
counter = [0.0]
|
||||
def fake_monotonic():
|
||||
counter[0] += 0.15 # each call advances 150ms
|
||||
return counter[0]
|
||||
mock_time.monotonic = fake_monotonic
|
||||
|
||||
t = threading.Thread(
|
||||
target=morse_decoder_thread,
|
||||
args=(fake_stdout, q, stop),
|
||||
)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
t.join(timeout=10)
|
||||
|
||||
events = []
|
||||
while not q.empty():
|
||||
events.append(q.get_nowait())
|
||||
|
||||
# Should have at least some events (scope or char)
|
||||
assert len(events) > 0, "Expected events from thread"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMorseRoutes:
|
||||
def test_start_missing_required_fields(self, client):
|
||||
"""Start should succeed with defaults."""
|
||||
_login_session(client)
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
m.setattr('app.morse_process', None)
|
||||
# Should fail because rtl_fm won't be found in test env
|
||||
resp = client.post('/morse/start', json={'frequency': '14.060'})
|
||||
assert resp.status_code in (200, 400, 409, 500)
|
||||
|
||||
def test_stop_when_not_running(self, client):
|
||||
"""Stop when nothing is running should return not_running."""
|
||||
_login_session(client)
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
m.setattr('app.morse_process', None)
|
||||
resp = client.post('/morse/stop')
|
||||
data = resp.get_json()
|
||||
assert data['status'] == 'not_running'
|
||||
|
||||
def test_status_when_not_running(self, client):
|
||||
"""Status should report not running."""
|
||||
_login_session(client)
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
m.setattr('app.morse_process', None)
|
||||
resp = client.get('/morse/status')
|
||||
data = resp.get_json()
|
||||
assert data['running'] is False
|
||||
|
||||
def test_invalid_tone_freq(self, client):
|
||||
"""Tone frequency outside range should be rejected."""
|
||||
_login_session(client)
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
m.setattr('app.morse_process', None)
|
||||
resp = client.post('/morse/start', json={
|
||||
'frequency': '14.060',
|
||||
'tone_freq': '50', # too low
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_invalid_wpm(self, client):
|
||||
"""WPM outside range should be rejected."""
|
||||
_login_session(client)
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
m.setattr('app.morse_process', None)
|
||||
resp = client.post('/morse/start', json={
|
||||
'frequency': '14.060',
|
||||
'wpm': '100', # too high
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_stream_endpoint_exists(self, client):
|
||||
"""Stream endpoint should return SSE content type."""
|
||||
_login_session(client)
|
||||
resp = client.get('/morse/stream')
|
||||
assert resp.content_type.startswith('text/event-stream')
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Tests for SSE fanout queue behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from utils.sse import subscribe_fanout_queue
|
||||
|
||||
|
||||
def _channel_key(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4()}"
|
||||
|
||||
|
||||
def test_fanout_drains_source_queue_without_subscribers() -> None:
|
||||
"""Queued messages should be dropped while no SSE clients are connected."""
|
||||
source = queue.Queue()
|
||||
channel_key = _channel_key("sse-idle")
|
||||
|
||||
# Start fanout distributor, then remove the only subscriber.
|
||||
_, unsubscribe = subscribe_fanout_queue(source, channel_key=channel_key, source_timeout=0.01)
|
||||
unsubscribe()
|
||||
|
||||
source.put({"type": "aprs", "callsign": "N0CALL"})
|
||||
time.sleep(0.05)
|
||||
|
||||
assert source.qsize() == 0
|
||||
|
||||
|
||||
def test_fanout_does_not_replay_stale_message_after_re_subscribe() -> None:
|
||||
"""A message queued while disconnected should not be replayed on reconnect."""
|
||||
source = queue.Queue()
|
||||
channel_key = _channel_key("sse-resub")
|
||||
|
||||
_, unsubscribe = subscribe_fanout_queue(source, channel_key=channel_key, source_timeout=0.01)
|
||||
unsubscribe()
|
||||
|
||||
source.put({"type": "aprs", "callsign": "K1ABC"})
|
||||
|
||||
subscriber, unsubscribe2 = subscribe_fanout_queue(
|
||||
source,
|
||||
channel_key=channel_key,
|
||||
source_timeout=0.01,
|
||||
)
|
||||
try:
|
||||
with pytest.raises(queue.Empty):
|
||||
subscriber.get(timeout=0.1)
|
||||
live = {"type": "aprs", "callsign": "LIVE01"}
|
||||
source.put(live)
|
||||
got = subscriber.get(timeout=0.25)
|
||||
finally:
|
||||
unsubscribe2()
|
||||
|
||||
assert got == live
|
||||
+214
-188
@@ -76,12 +76,12 @@ class TestReceive:
|
||||
mock_proc.stderr = MagicMock()
|
||||
mock_proc.stderr.readline = MagicMock(return_value=b'')
|
||||
|
||||
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
|
||||
patch('subprocess.Popen', return_value=mock_proc), \
|
||||
patch.object(manager, 'check_hackrf_device', return_value=True), \
|
||||
patch('utils.subghz.register_process'):
|
||||
manager._hackrf_available = None
|
||||
result = manager.start_receive(
|
||||
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
|
||||
patch('subprocess.Popen', return_value=mock_proc), \
|
||||
patch.object(manager, 'check_hackrf_device', return_value=True), \
|
||||
patch('utils.subghz.register_process'):
|
||||
manager._hackrf_available = None
|
||||
result = manager.start_receive(
|
||||
frequency_hz=433920000,
|
||||
sample_rate=2000000,
|
||||
lna_gain=32,
|
||||
@@ -92,9 +92,14 @@ class TestReceive:
|
||||
assert manager.active_mode == 'rx'
|
||||
|
||||
def test_start_receive_already_running(self, manager):
|
||||
import time as _time
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
manager._rx_process = mock_proc
|
||||
# Pre-lock device checks now run before active_mode guard
|
||||
manager._hackrf_available = True
|
||||
manager._hackrf_device_cache = True
|
||||
manager._hackrf_device_cache_ts = _time.time()
|
||||
|
||||
result = manager.start_receive(frequency_hz=433920000)
|
||||
assert result['status'] == 'error'
|
||||
@@ -104,10 +109,10 @@ class TestReceive:
|
||||
result = manager.stop_receive()
|
||||
assert result['status'] == 'not_running'
|
||||
|
||||
def test_stop_receive_creates_metadata(self, manager, tmp_data_dir):
|
||||
# Create a fake IQ file
|
||||
iq_file = tmp_data_dir / 'captures' / 'test.iq'
|
||||
iq_file.write_bytes(b'\x00' * 1024)
|
||||
def test_stop_receive_creates_metadata(self, manager, tmp_data_dir):
|
||||
# Create a fake IQ file
|
||||
iq_file = tmp_data_dir / 'captures' / 'test.iq'
|
||||
iq_file.write_bytes(b'\x00' * 1024)
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
@@ -115,10 +120,10 @@ class TestReceive:
|
||||
manager._rx_file = iq_file
|
||||
manager._rx_frequency_hz = 433920000
|
||||
manager._rx_sample_rate = 2000000
|
||||
manager._rx_lna_gain = 32
|
||||
manager._rx_vga_gain = 20
|
||||
manager._rx_start_time = 1000.0
|
||||
manager._rx_bursts = [{'start_seconds': 1.23, 'duration_seconds': 0.15, 'peak_level': 42}]
|
||||
manager._rx_lna_gain = 32
|
||||
manager._rx_vga_gain = 20
|
||||
manager._rx_start_time = 1000.0
|
||||
manager._rx_bursts = [{'start_seconds': 1.23, 'duration_seconds': 0.15, 'peak_level': 42}]
|
||||
|
||||
with patch('utils.subghz.safe_terminate'), \
|
||||
patch('time.time', return_value=1005.0):
|
||||
@@ -131,10 +136,10 @@ class TestReceive:
|
||||
# Verify JSON sidecar was written
|
||||
meta_path = iq_file.with_suffix('.json')
|
||||
assert meta_path.exists()
|
||||
meta = json.loads(meta_path.read_text())
|
||||
assert meta['frequency_hz'] == 433920000
|
||||
assert isinstance(meta.get('bursts'), list)
|
||||
assert meta['bursts'][0]['peak_level'] == 42
|
||||
meta = json.loads(meta_path.read_text())
|
||||
assert meta['frequency_hz'] == 433920000
|
||||
assert isinstance(meta.get('bursts'), list)
|
||||
assert meta['bursts'][0]['peak_level'] == 42
|
||||
|
||||
|
||||
class TestTxSafety:
|
||||
@@ -165,13 +170,13 @@ class TestTxSafety:
|
||||
result = manager.transmit(capture_id='abc123')
|
||||
assert result['status'] == 'error'
|
||||
|
||||
def test_transmit_capture_not_found(self, manager):
|
||||
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
|
||||
patch.object(manager, 'check_hackrf_device', return_value=True):
|
||||
manager._hackrf_available = None
|
||||
result = manager.transmit(capture_id='nonexistent')
|
||||
assert result['status'] == 'error'
|
||||
assert 'not found' in result['message']
|
||||
def test_transmit_capture_not_found(self, manager):
|
||||
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
|
||||
patch.object(manager, 'check_hackrf_device', return_value=True):
|
||||
manager._hackrf_available = None
|
||||
result = manager.transmit(capture_id='nonexistent')
|
||||
assert result['status'] == 'error'
|
||||
assert 'not found' in result['message']
|
||||
|
||||
def test_transmit_out_of_band_rejected(self, manager, tmp_data_dir):
|
||||
# Create a capture with out-of-band frequency
|
||||
@@ -188,64 +193,79 @@ class TestTxSafety:
|
||||
meta_path.write_text(json.dumps(meta))
|
||||
(tmp_data_dir / 'captures' / 'test.iq').write_bytes(b'\x00' * 100)
|
||||
|
||||
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
|
||||
patch.object(manager, 'check_hackrf_device', return_value=True):
|
||||
manager._hackrf_available = None
|
||||
result = manager.transmit(capture_id='test123')
|
||||
assert result['status'] == 'error'
|
||||
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
|
||||
patch.object(manager, 'check_hackrf_device', return_value=True):
|
||||
manager._hackrf_available = None
|
||||
result = manager.transmit(capture_id='test123')
|
||||
assert result['status'] == 'error'
|
||||
assert 'outside allowed TX bands' in result['message']
|
||||
|
||||
def test_transmit_already_running(self, manager):
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
manager._rx_process = mock_proc
|
||||
|
||||
result = manager.transmit(capture_id='test123')
|
||||
assert result['status'] == 'error'
|
||||
assert 'Already running' in result['message']
|
||||
|
||||
def test_transmit_segment_extracts_range(self, manager, tmp_data_dir):
|
||||
meta = {
|
||||
'id': 'seg001',
|
||||
'filename': 'seg.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 1000,
|
||||
'lna_gain': 24,
|
||||
'vga_gain': 20,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'duration_seconds': 1.0,
|
||||
'size_bytes': 2000,
|
||||
}
|
||||
(tmp_data_dir / 'captures' / 'seg.json').write_text(json.dumps(meta))
|
||||
(tmp_data_dir / 'captures' / 'seg.iq').write_bytes(bytes(range(200)) * 10)
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
mock_timer = MagicMock()
|
||||
mock_timer.start = MagicMock()
|
||||
|
||||
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
|
||||
patch.object(manager, 'check_hackrf_device', return_value=True), \
|
||||
patch('subprocess.Popen', return_value=mock_proc), \
|
||||
patch('utils.subghz.register_process'), \
|
||||
patch('threading.Timer', return_value=mock_timer), \
|
||||
patch('threading.Thread') as mock_thread_cls:
|
||||
mock_thread = MagicMock()
|
||||
mock_thread.start = MagicMock()
|
||||
mock_thread_cls.return_value = mock_thread
|
||||
|
||||
manager._hackrf_available = None
|
||||
result = manager.transmit(
|
||||
capture_id='seg001',
|
||||
start_seconds=0.2,
|
||||
duration_seconds=0.3,
|
||||
)
|
||||
|
||||
assert result['status'] == 'transmitting'
|
||||
assert result['segment'] is not None
|
||||
assert result['segment']['duration_seconds'] == pytest.approx(0.3, abs=0.01)
|
||||
assert manager._tx_temp_file is not None
|
||||
assert manager._tx_temp_file.exists()
|
||||
def test_transmit_already_running(self, manager, tmp_data_dir):
|
||||
import time as _time
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
manager._rx_process = mock_proc
|
||||
# Pre-lock device checks now run before active_mode guard
|
||||
manager._hackrf_available = True
|
||||
manager._hackrf_device_cache = True
|
||||
manager._hackrf_device_cache_ts = _time.time()
|
||||
# Capture lookup also runs pre-lock now; provide a valid capture + IQ file
|
||||
meta = {
|
||||
'id': 'test123',
|
||||
'filename': 'test.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 2000000,
|
||||
'timestamp': '2025-01-01T00:00:00',
|
||||
}
|
||||
(tmp_data_dir / 'captures' / 'test.json').write_text(json.dumps(meta))
|
||||
(tmp_data_dir / 'captures' / 'test.iq').write_bytes(b'\x00' * 64)
|
||||
|
||||
result = manager.transmit(capture_id='test123')
|
||||
assert result['status'] == 'error'
|
||||
assert 'Already running' in result['message']
|
||||
|
||||
def test_transmit_segment_extracts_range(self, manager, tmp_data_dir):
|
||||
meta = {
|
||||
'id': 'seg001',
|
||||
'filename': 'seg.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 1000,
|
||||
'lna_gain': 24,
|
||||
'vga_gain': 20,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'duration_seconds': 1.0,
|
||||
'size_bytes': 2000,
|
||||
}
|
||||
(tmp_data_dir / 'captures' / 'seg.json').write_text(json.dumps(meta))
|
||||
(tmp_data_dir / 'captures' / 'seg.iq').write_bytes(bytes(range(200)) * 10)
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
mock_timer = MagicMock()
|
||||
mock_timer.start = MagicMock()
|
||||
|
||||
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
|
||||
patch.object(manager, 'check_hackrf_device', return_value=True), \
|
||||
patch('subprocess.Popen', return_value=mock_proc), \
|
||||
patch('utils.subghz.register_process'), \
|
||||
patch('threading.Timer', return_value=mock_timer), \
|
||||
patch('threading.Thread') as mock_thread_cls:
|
||||
mock_thread = MagicMock()
|
||||
mock_thread.start = MagicMock()
|
||||
mock_thread_cls.return_value = mock_thread
|
||||
|
||||
manager._hackrf_available = None
|
||||
result = manager.transmit(
|
||||
capture_id='seg001',
|
||||
start_seconds=0.2,
|
||||
duration_seconds=0.3,
|
||||
)
|
||||
|
||||
assert result['status'] == 'transmitting'
|
||||
assert result['segment'] is not None
|
||||
assert result['segment']['duration_seconds'] == pytest.approx(0.3, abs=0.01)
|
||||
assert manager._tx_temp_file is not None
|
||||
assert manager._tx_temp_file.exists()
|
||||
|
||||
|
||||
class TestCaptureLibrary:
|
||||
@@ -311,11 +331,11 @@ class TestCaptureLibrary:
|
||||
def test_delete_capture_not_found(self, manager):
|
||||
assert manager.delete_capture('nonexistent') is False
|
||||
|
||||
def test_update_label(self, manager, tmp_data_dir):
|
||||
meta = {
|
||||
'id': 'lbl001',
|
||||
'filename': 'label_test.iq',
|
||||
'frequency_hz': 433920000,
|
||||
def test_update_label(self, manager, tmp_data_dir):
|
||||
meta = {
|
||||
'id': 'lbl001',
|
||||
'filename': 'label_test.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 2000000,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'label': '',
|
||||
@@ -324,10 +344,10 @@ class TestCaptureLibrary:
|
||||
meta_path.write_text(json.dumps(meta))
|
||||
|
||||
assert manager.update_capture_label('lbl001', 'Garage Remote') is True
|
||||
|
||||
updated = json.loads(meta_path.read_text())
|
||||
assert updated['label'] == 'Garage Remote'
|
||||
assert updated['label_source'] == 'manual'
|
||||
|
||||
updated = json.loads(meta_path.read_text())
|
||||
assert updated['label'] == 'Garage Remote'
|
||||
assert updated['label_source'] == 'manual'
|
||||
|
||||
def test_update_label_not_found(self, manager):
|
||||
assert manager.update_capture_label('nonexistent', 'test') is False
|
||||
@@ -348,100 +368,100 @@ class TestCaptureLibrary:
|
||||
assert path is not None
|
||||
assert path.name == 'path_test.iq'
|
||||
|
||||
def test_get_capture_path_not_found(self, manager):
|
||||
assert manager.get_capture_path('nonexistent') is None
|
||||
|
||||
def test_trim_capture_manual_segment(self, manager, tmp_data_dir):
|
||||
captures_dir = tmp_data_dir / 'captures'
|
||||
iq_path = captures_dir / 'trim_src.iq'
|
||||
iq_path.write_bytes(bytes(range(200)) * 20) # 4000 bytes at 1000 sps => 2.0s
|
||||
(captures_dir / 'trim_src.json').write_text(json.dumps({
|
||||
'id': 'trim001',
|
||||
'filename': 'trim_src.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 1000,
|
||||
'lna_gain': 24,
|
||||
'vga_gain': 20,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'duration_seconds': 2.0,
|
||||
'size_bytes': 4000,
|
||||
'label': 'Weather Burst',
|
||||
'bursts': [
|
||||
{
|
||||
'start_seconds': 0.55,
|
||||
'duration_seconds': 0.2,
|
||||
'peak_level': 67,
|
||||
'fingerprint': 'abc123',
|
||||
'modulation_hint': 'OOK/ASK',
|
||||
'modulation_confidence': 0.9,
|
||||
}
|
||||
],
|
||||
}))
|
||||
|
||||
result = manager.trim_capture(
|
||||
capture_id='trim001',
|
||||
start_seconds=0.5,
|
||||
duration_seconds=0.4,
|
||||
)
|
||||
|
||||
assert result['status'] == 'ok'
|
||||
assert result['capture']['id'] != 'trim001'
|
||||
assert result['capture']['size_bytes'] == 800
|
||||
assert result['capture']['label'].endswith('(Trim)')
|
||||
trimmed_iq = captures_dir / result['capture']['filename']
|
||||
assert trimmed_iq.exists()
|
||||
trimmed_meta = trimmed_iq.with_suffix('.json')
|
||||
assert trimmed_meta.exists()
|
||||
|
||||
def test_trim_capture_auto_burst(self, manager, tmp_data_dir):
|
||||
captures_dir = tmp_data_dir / 'captures'
|
||||
iq_path = captures_dir / 'auto_src.iq'
|
||||
iq_path.write_bytes(bytes(range(100)) * 40) # 4000 bytes
|
||||
(captures_dir / 'auto_src.json').write_text(json.dumps({
|
||||
'id': 'trim002',
|
||||
'filename': 'auto_src.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 1000,
|
||||
'lna_gain': 24,
|
||||
'vga_gain': 20,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'duration_seconds': 2.0,
|
||||
'size_bytes': 4000,
|
||||
'bursts': [
|
||||
{'start_seconds': 0.2, 'duration_seconds': 0.1, 'peak_level': 12},
|
||||
{'start_seconds': 1.2, 'duration_seconds': 0.25, 'peak_level': 88},
|
||||
],
|
||||
}))
|
||||
|
||||
result = manager.trim_capture(capture_id='trim002')
|
||||
assert result['status'] == 'ok'
|
||||
assert result['segment']['auto_selected'] is True
|
||||
assert result['capture']['duration_seconds'] > 0.25
|
||||
|
||||
def test_list_captures_groups_same_fingerprint(self, manager, tmp_data_dir):
|
||||
cap_a = {
|
||||
'id': 'grp001',
|
||||
'filename': 'a.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 2000000,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'dominant_fingerprint': 'deadbeefcafebabe',
|
||||
}
|
||||
cap_b = {
|
||||
'id': 'grp002',
|
||||
'filename': 'b.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 2000000,
|
||||
'timestamp': '2026-01-01T00:01:00Z',
|
||||
'dominant_fingerprint': 'deadbeefcafebabe',
|
||||
}
|
||||
(tmp_data_dir / 'captures' / 'a.json').write_text(json.dumps(cap_a))
|
||||
(tmp_data_dir / 'captures' / 'b.json').write_text(json.dumps(cap_b))
|
||||
|
||||
captures = manager.list_captures()
|
||||
assert len(captures) == 2
|
||||
assert all(c.fingerprint_group.startswith('SIG-') for c in captures)
|
||||
assert all(c.fingerprint_group_size == 2 for c in captures)
|
||||
def test_get_capture_path_not_found(self, manager):
|
||||
assert manager.get_capture_path('nonexistent') is None
|
||||
|
||||
def test_trim_capture_manual_segment(self, manager, tmp_data_dir):
|
||||
captures_dir = tmp_data_dir / 'captures'
|
||||
iq_path = captures_dir / 'trim_src.iq'
|
||||
iq_path.write_bytes(bytes(range(200)) * 20) # 4000 bytes at 1000 sps => 2.0s
|
||||
(captures_dir / 'trim_src.json').write_text(json.dumps({
|
||||
'id': 'trim001',
|
||||
'filename': 'trim_src.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 1000,
|
||||
'lna_gain': 24,
|
||||
'vga_gain': 20,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'duration_seconds': 2.0,
|
||||
'size_bytes': 4000,
|
||||
'label': 'Weather Burst',
|
||||
'bursts': [
|
||||
{
|
||||
'start_seconds': 0.55,
|
||||
'duration_seconds': 0.2,
|
||||
'peak_level': 67,
|
||||
'fingerprint': 'abc123',
|
||||
'modulation_hint': 'OOK/ASK',
|
||||
'modulation_confidence': 0.9,
|
||||
}
|
||||
],
|
||||
}))
|
||||
|
||||
result = manager.trim_capture(
|
||||
capture_id='trim001',
|
||||
start_seconds=0.5,
|
||||
duration_seconds=0.4,
|
||||
)
|
||||
|
||||
assert result['status'] == 'ok'
|
||||
assert result['capture']['id'] != 'trim001'
|
||||
assert result['capture']['size_bytes'] == 800
|
||||
assert result['capture']['label'].endswith('(Trim)')
|
||||
trimmed_iq = captures_dir / result['capture']['filename']
|
||||
assert trimmed_iq.exists()
|
||||
trimmed_meta = trimmed_iq.with_suffix('.json')
|
||||
assert trimmed_meta.exists()
|
||||
|
||||
def test_trim_capture_auto_burst(self, manager, tmp_data_dir):
|
||||
captures_dir = tmp_data_dir / 'captures'
|
||||
iq_path = captures_dir / 'auto_src.iq'
|
||||
iq_path.write_bytes(bytes(range(100)) * 40) # 4000 bytes
|
||||
(captures_dir / 'auto_src.json').write_text(json.dumps({
|
||||
'id': 'trim002',
|
||||
'filename': 'auto_src.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 1000,
|
||||
'lna_gain': 24,
|
||||
'vga_gain': 20,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'duration_seconds': 2.0,
|
||||
'size_bytes': 4000,
|
||||
'bursts': [
|
||||
{'start_seconds': 0.2, 'duration_seconds': 0.1, 'peak_level': 12},
|
||||
{'start_seconds': 1.2, 'duration_seconds': 0.25, 'peak_level': 88},
|
||||
],
|
||||
}))
|
||||
|
||||
result = manager.trim_capture(capture_id='trim002')
|
||||
assert result['status'] == 'ok'
|
||||
assert result['segment']['auto_selected'] is True
|
||||
assert result['capture']['duration_seconds'] > 0.25
|
||||
|
||||
def test_list_captures_groups_same_fingerprint(self, manager, tmp_data_dir):
|
||||
cap_a = {
|
||||
'id': 'grp001',
|
||||
'filename': 'a.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 2000000,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'dominant_fingerprint': 'deadbeefcafebabe',
|
||||
}
|
||||
cap_b = {
|
||||
'id': 'grp002',
|
||||
'filename': 'b.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 2000000,
|
||||
'timestamp': '2026-01-01T00:01:00Z',
|
||||
'dominant_fingerprint': 'deadbeefcafebabe',
|
||||
}
|
||||
(tmp_data_dir / 'captures' / 'a.json').write_text(json.dumps(cap_a))
|
||||
(tmp_data_dir / 'captures' / 'b.json').write_text(json.dumps(cap_b))
|
||||
|
||||
captures = manager.list_captures()
|
||||
assert len(captures) == 2
|
||||
assert all(c.fingerprint_group.startswith('SIG-') for c in captures)
|
||||
assert all(c.fingerprint_group_size == 2 for c in captures)
|
||||
|
||||
|
||||
class TestSweep:
|
||||
@@ -452,6 +472,7 @@ class TestSweep:
|
||||
assert result['status'] == 'error'
|
||||
|
||||
def test_start_sweep_success(self, manager):
|
||||
import time as _time
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
mock_proc.stdout = MagicMock()
|
||||
@@ -460,6 +481,8 @@ class TestSweep:
|
||||
patch('subprocess.Popen', return_value=mock_proc), \
|
||||
patch('utils.subghz.register_process'):
|
||||
manager._sweep_available = None
|
||||
manager._hackrf_device_cache = True
|
||||
manager._hackrf_device_cache_ts = _time.time()
|
||||
result = manager.start_sweep(freq_start_mhz=300, freq_end_mhz=928)
|
||||
assert result['status'] == 'started'
|
||||
|
||||
@@ -517,8 +540,11 @@ class TestDecode:
|
||||
with patch('shutil.which', return_value='/usr/bin/tool'), \
|
||||
patch('subprocess.Popen', side_effect=popen_side_effect) as mock_popen, \
|
||||
patch('utils.subghz.register_process'):
|
||||
import time as _time
|
||||
manager._hackrf_available = None
|
||||
manager._rtl433_available = None
|
||||
manager._hackrf_device_cache = True
|
||||
manager._hackrf_device_cache_ts = _time.time()
|
||||
result = manager.start_decode(
|
||||
frequency_hz=433920000,
|
||||
sample_rate=2000000,
|
||||
@@ -536,10 +562,10 @@ class TestDecode:
|
||||
assert '-r' in hackrf_cmd
|
||||
|
||||
# Verify rtl_433 command
|
||||
rtl433_cmd = mock_popen.call_args_list[1][0][0]
|
||||
assert rtl433_cmd[0] == 'rtl_433'
|
||||
assert '-r' in rtl433_cmd
|
||||
assert 'cs8:-' in rtl433_cmd
|
||||
rtl433_cmd = mock_popen.call_args_list[1][0][0]
|
||||
assert rtl433_cmd[0] == 'rtl_433'
|
||||
assert '-r' in rtl433_cmd
|
||||
assert 'cs8:-' in rtl433_cmd
|
||||
|
||||
# Both processes tracked
|
||||
assert manager._decode_hackrf_process is mock_hackrf_proc
|
||||
|
||||
@@ -73,9 +73,10 @@ class TestWeatherSatDecoder:
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
|
||||
success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
|
||||
|
||||
assert success is False
|
||||
assert error_msg is not None
|
||||
callback.assert_called()
|
||||
progress = callback.call_args[0][0]
|
||||
assert progress.status == 'error'
|
||||
@@ -88,7 +89,7 @@ class TestWeatherSatDecoder:
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success = decoder.start(satellite='FAKE-SAT', device_index=0, gain=40.0)
|
||||
success, error_msg = decoder.start(satellite='FAKE-SAT', device_index=0, gain=40.0)
|
||||
|
||||
assert success is False
|
||||
callback.assert_called()
|
||||
@@ -113,7 +114,7 @@ class TestWeatherSatDecoder:
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success = decoder.start(
|
||||
success, error_msg = decoder.start(
|
||||
satellite='NOAA-18',
|
||||
device_index=0,
|
||||
gain=40.0,
|
||||
@@ -121,6 +122,7 @@ class TestWeatherSatDecoder:
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert error_msg is None
|
||||
assert decoder.is_running is True
|
||||
assert decoder.current_satellite == 'NOAA-18'
|
||||
assert decoder.current_frequency == 137.9125
|
||||
@@ -138,13 +140,15 @@ class TestWeatherSatDecoder:
|
||||
@patch('pty.openpty')
|
||||
def test_start_already_running(self, mock_pty, mock_popen):
|
||||
"""start() should return True when already running."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'), \
|
||||
patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'):
|
||||
decoder = WeatherSatDecoder()
|
||||
decoder._running = True
|
||||
|
||||
success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
|
||||
success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
|
||||
|
||||
assert success is True
|
||||
assert error_msg is None
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
@patch('subprocess.Popen')
|
||||
@@ -159,9 +163,10 @@ class TestWeatherSatDecoder:
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
|
||||
success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
|
||||
|
||||
assert success is False
|
||||
assert error_msg is not None
|
||||
assert decoder.is_running is False
|
||||
callback.assert_called()
|
||||
progress = callback.call_args[0][0]
|
||||
@@ -174,12 +179,13 @@ class TestWeatherSatDecoder:
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success = decoder.start_from_file(
|
||||
success, error_msg = decoder.start_from_file(
|
||||
satellite='NOAA-18',
|
||||
input_file='data/test.wav',
|
||||
)
|
||||
|
||||
assert success is False
|
||||
assert error_msg is not None
|
||||
callback.assert_called()
|
||||
|
||||
@patch('subprocess.Popen')
|
||||
@@ -199,19 +205,21 @@ class TestWeatherSatDecoder:
|
||||
|
||||
mock_pty.return_value = (10, 11)
|
||||
mock_process = MagicMock()
|
||||
mock_process.poll.return_value = None # Process still running
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
decoder = WeatherSatDecoder()
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success = decoder.start_from_file(
|
||||
success, error_msg = decoder.start_from_file(
|
||||
satellite='NOAA-18',
|
||||
input_file='data/test.wav',
|
||||
sample_rate=1000000,
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert error_msg is None
|
||||
assert decoder.is_running is True
|
||||
assert decoder.current_satellite == 'NOAA-18'
|
||||
|
||||
@@ -235,7 +243,7 @@ class TestWeatherSatDecoder:
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success = decoder.start_from_file(
|
||||
success, error_msg = decoder.start_from_file(
|
||||
satellite='NOAA-18',
|
||||
input_file='/etc/passwd',
|
||||
)
|
||||
@@ -258,7 +266,7 @@ class TestWeatherSatDecoder:
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success = decoder.start_from_file(
|
||||
success, error_msg = decoder.start_from_file(
|
||||
satellite='NOAA-18',
|
||||
input_file='data/missing.wav',
|
||||
)
|
||||
@@ -425,12 +433,12 @@ class TestWeatherSatDecoder:
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_resolve_device_id_fallback(self, mock_run):
|
||||
"""_resolve_device_id() should fall back to index string."""
|
||||
"""_resolve_device_id() should return None when no serial found."""
|
||||
mock_run.side_effect = FileNotFoundError
|
||||
|
||||
serial = WeatherSatDecoder._resolve_device_id(0)
|
||||
|
||||
assert serial == '0'
|
||||
assert serial is None
|
||||
|
||||
def test_parse_product_name_rgb(self):
|
||||
"""_parse_product_name() should identify RGB composite."""
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""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, error_msg = 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 error_msg is None
|
||||
assert decoder.device_index == -1
|
||||
mock_start.assert_called_once()
|
||||
|
||||
decoder.stop()
|
||||
assert decoder.device_index == -1
|
||||
@@ -73,7 +73,7 @@ class TestWeatherSatRoutes:
|
||||
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = True
|
||||
mock_decoder.start.return_value = (True, None)
|
||||
mock_get.return_value = mock_decoder
|
||||
|
||||
payload = {
|
||||
@@ -233,7 +233,7 @@ class TestWeatherSatRoutes:
|
||||
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = False
|
||||
mock_decoder.start.return_value = (False, 'SatDump exited immediately (code 1)')
|
||||
mock_get.return_value = mock_decoder
|
||||
|
||||
payload = {'satellite': 'NOAA-18'}
|
||||
@@ -246,7 +246,7 @@ class TestWeatherSatRoutes:
|
||||
assert response.status_code == 500
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'error'
|
||||
assert 'Failed to start capture' in data['message']
|
||||
assert 'SatDump exited immediately' in data['message']
|
||||
|
||||
def test_test_decode_success(self, client):
|
||||
"""POST /weather-sat/test-decode successfully starts file decode."""
|
||||
@@ -262,7 +262,7 @@ class TestWeatherSatRoutes:
|
||||
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start_from_file.return_value = True
|
||||
mock_decoder.start_from_file.return_value = (True, None)
|
||||
mock_get.return_value = mock_decoder
|
||||
|
||||
payload = {
|
||||
|
||||
@@ -546,7 +546,7 @@ class TestWeatherSatScheduler:
|
||||
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = True
|
||||
mock_decoder.start.return_value = (True, None)
|
||||
mock_get.return_value = mock_decoder
|
||||
|
||||
mock_timer_instance = MagicMock()
|
||||
@@ -590,7 +590,7 @@ class TestWeatherSatScheduler:
|
||||
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = False
|
||||
mock_decoder.start.return_value = (False, 'Start failed')
|
||||
mock_get.return_value = mock_decoder
|
||||
|
||||
pass_data = {
|
||||
@@ -798,7 +798,7 @@ class TestSchedulerIntegration:
|
||||
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = True
|
||||
mock_decoder.start.return_value = (True, None)
|
||||
mock_get_decoder.return_value = mock_decoder
|
||||
|
||||
scheduler = WeatherSatScheduler()
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
"""Tests for WeFax (Weather Fax) routes, decoder, and station loader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _login_session(client) -> None:
|
||||
"""Mark the Flask test session as authenticated."""
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
sess['username'] = 'test'
|
||||
sess['role'] = 'admin'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Station database tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWeFaxStations:
|
||||
"""WeFax station database tests."""
|
||||
|
||||
def test_load_stations_returns_list(self):
|
||||
"""load_stations() should return a non-empty list."""
|
||||
from utils.wefax_stations import load_stations
|
||||
stations = load_stations()
|
||||
assert isinstance(stations, list)
|
||||
assert len(stations) >= 10
|
||||
|
||||
def test_station_has_required_fields(self):
|
||||
"""Each station must have required fields."""
|
||||
from utils.wefax_stations import load_stations
|
||||
required = {'name', 'callsign', 'country', 'city', 'coordinates',
|
||||
'frequencies', 'ioc', 'lpm', 'schedule'}
|
||||
for station in load_stations():
|
||||
missing = required - set(station.keys())
|
||||
assert not missing, f"Station {station.get('callsign', '?')} missing: {missing}"
|
||||
|
||||
def test_get_station_by_callsign(self):
|
||||
"""get_station() should return correct station."""
|
||||
from utils.wefax_stations import get_station
|
||||
station = get_station('NOJ')
|
||||
assert station is not None
|
||||
assert station['callsign'] == 'NOJ'
|
||||
assert station['country'] == 'US'
|
||||
|
||||
def test_get_station_case_insensitive(self):
|
||||
"""get_station() should be case-insensitive."""
|
||||
from utils.wefax_stations import get_station
|
||||
assert get_station('noj') is not None
|
||||
|
||||
def test_get_station_not_found(self):
|
||||
"""get_station() should return None for unknown callsign."""
|
||||
from utils.wefax_stations import get_station
|
||||
assert get_station('XXXXX') is None
|
||||
|
||||
def test_resolve_tuning_frequency_auto_uses_carrier_for_known_station(self):
|
||||
"""Known station frequencies default to carrier-list behavior in auto mode."""
|
||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||
|
||||
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=4298.0,
|
||||
station_callsign='NOJ',
|
||||
frequency_reference='auto',
|
||||
)
|
||||
|
||||
assert math.isclose(tuned, 4296.1, abs_tol=1e-6)
|
||||
assert reference == 'carrier'
|
||||
assert offset_applied is True
|
||||
|
||||
def test_resolve_tuning_frequency_auto_preserves_unknown_station_input(self):
|
||||
"""Ad-hoc frequencies (no station metadata) should be treated as dial."""
|
||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||
|
||||
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=4298.0,
|
||||
station_callsign='',
|
||||
frequency_reference='auto',
|
||||
)
|
||||
|
||||
assert math.isclose(tuned, 4298.0, abs_tol=1e-6)
|
||||
assert reference == 'dial'
|
||||
assert offset_applied is False
|
||||
|
||||
def test_resolve_tuning_frequency_dial_override(self):
|
||||
"""Explicit dial reference must bypass USB alignment."""
|
||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||
|
||||
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=4298.0,
|
||||
station_callsign='NOJ',
|
||||
frequency_reference='dial',
|
||||
)
|
||||
|
||||
assert math.isclose(tuned, 4298.0, abs_tol=1e-6)
|
||||
assert reference == 'dial'
|
||||
assert offset_applied is False
|
||||
|
||||
def test_resolve_tuning_frequency_rejects_invalid_reference(self):
|
||||
"""Invalid frequency reference values should raise a validation error."""
|
||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||
|
||||
try:
|
||||
resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=4298.0,
|
||||
station_callsign='NOJ',
|
||||
frequency_reference='invalid',
|
||||
)
|
||||
assert False, "Expected ValueError for invalid frequency_reference"
|
||||
except ValueError as exc:
|
||||
assert 'frequency_reference' in str(exc)
|
||||
|
||||
def test_station_frequencies_have_khz(self):
|
||||
"""Each frequency entry must have 'khz' and 'description'."""
|
||||
from utils.wefax_stations import load_stations
|
||||
for station in load_stations():
|
||||
for freq in station['frequencies']:
|
||||
assert 'khz' in freq, f"{station['callsign']} missing khz"
|
||||
assert 'description' in freq, f"{station['callsign']} missing description"
|
||||
assert isinstance(freq['khz'], (int, float))
|
||||
assert freq['khz'] > 0
|
||||
|
||||
def test_schedule_format(self):
|
||||
"""Schedule entries must have utc, duration_min, content."""
|
||||
from utils.wefax_stations import load_stations
|
||||
for station in load_stations():
|
||||
for entry in station['schedule']:
|
||||
assert 'utc' in entry
|
||||
assert 'duration_min' in entry
|
||||
assert 'content' in entry
|
||||
# UTC format: HH:MM
|
||||
parts = entry['utc'].split(':')
|
||||
assert len(parts) == 2
|
||||
assert 0 <= int(parts[0]) <= 23
|
||||
assert 0 <= int(parts[1]) <= 59
|
||||
|
||||
def test_get_current_broadcasts(self):
|
||||
"""get_current_broadcasts() should return up to 3 entries."""
|
||||
from utils.wefax_stations import get_current_broadcasts
|
||||
broadcasts = get_current_broadcasts('NOJ')
|
||||
assert isinstance(broadcasts, list)
|
||||
assert len(broadcasts) <= 3
|
||||
for b in broadcasts:
|
||||
assert 'utc' in b
|
||||
assert 'content' in b
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decoder unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWeFaxDecoder:
|
||||
"""WeFax decoder DSP and data class tests."""
|
||||
|
||||
def test_freq_to_pixel_black(self):
|
||||
"""1500 Hz should map to 0 (black)."""
|
||||
from utils.wefax import _freq_to_pixel
|
||||
assert _freq_to_pixel(1500.0) == 0
|
||||
|
||||
def test_freq_to_pixel_white(self):
|
||||
"""2300 Hz should map to 255 (white)."""
|
||||
from utils.wefax import _freq_to_pixel
|
||||
assert _freq_to_pixel(2300.0) == 255
|
||||
|
||||
def test_freq_to_pixel_mid(self):
|
||||
"""1900 Hz (carrier) should map to ~128."""
|
||||
from utils.wefax import _freq_to_pixel
|
||||
val = _freq_to_pixel(1900.0)
|
||||
assert 120 <= val <= 135
|
||||
|
||||
def test_freq_to_pixel_clamp_low(self):
|
||||
"""Below 1500 Hz should clamp to 0."""
|
||||
from utils.wefax import _freq_to_pixel
|
||||
assert _freq_to_pixel(1000.0) == 0
|
||||
|
||||
def test_freq_to_pixel_clamp_high(self):
|
||||
"""Above 2300 Hz should clamp to 255."""
|
||||
from utils.wefax import _freq_to_pixel
|
||||
assert _freq_to_pixel(3000.0) == 255
|
||||
|
||||
def test_ioc_576_pixel_count(self):
|
||||
"""IOC 576 should give pi*576 ≈ 1809 pixels per line."""
|
||||
pixels = int(math.pi * 576)
|
||||
assert pixels == 1809
|
||||
|
||||
def test_ioc_288_pixel_count(self):
|
||||
"""IOC 288 should give pi*288 ≈ 904 pixels per line."""
|
||||
pixels = int(math.pi * 288)
|
||||
assert pixels == 904
|
||||
|
||||
def test_goertzel_mag_detects_tone(self):
|
||||
"""Goertzel should detect a pure tone."""
|
||||
from utils.wefax import _goertzel_mag
|
||||
sr = 22050
|
||||
freq = 1900.0
|
||||
t = np.arange(sr) / sr
|
||||
samples = np.sin(2 * np.pi * freq * t)
|
||||
mag = _goertzel_mag(samples[:2205], freq, sr)
|
||||
# Should be significantly non-zero for a matching tone
|
||||
assert mag > 1.0
|
||||
|
||||
def test_goertzel_mag_rejects_wrong_freq(self):
|
||||
"""Goertzel should be much weaker for non-matching frequency."""
|
||||
from utils.wefax import _goertzel_mag
|
||||
sr = 22050
|
||||
t = np.arange(sr) / sr
|
||||
samples = np.sin(2 * np.pi * 1900.0 * t)
|
||||
mag_match = _goertzel_mag(samples[:2205], 1900.0, sr)
|
||||
mag_off = _goertzel_mag(samples[:2205], 300.0, sr)
|
||||
assert mag_match > mag_off * 5
|
||||
|
||||
def test_detect_tone_start(self):
|
||||
"""detect_tone should identify a 300 Hz start tone."""
|
||||
from utils.wefax import _detect_tone
|
||||
sr = 22050
|
||||
t = np.arange(sr) / sr
|
||||
samples = np.sin(2 * np.pi * 300.0 * t)
|
||||
assert _detect_tone(samples[:2205], 300.0, sr, threshold=2.0)
|
||||
|
||||
def test_wefax_image_to_dict(self):
|
||||
"""WeFaxImage.to_dict() should produce expected format."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from utils.wefax import WeFaxImage
|
||||
img = WeFaxImage(
|
||||
filename='test.png',
|
||||
path=Path('/tmp/test.png'),
|
||||
station='NOJ',
|
||||
frequency_khz=4298,
|
||||
timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
ioc=576,
|
||||
lpm=120,
|
||||
size_bytes=1234,
|
||||
)
|
||||
d = img.to_dict()
|
||||
assert d['filename'] == 'test.png'
|
||||
assert d['station'] == 'NOJ'
|
||||
assert d['frequency_khz'] == 4298
|
||||
assert d['ioc'] == 576
|
||||
assert d['url'] == '/wefax/images/test.png'
|
||||
|
||||
def test_wefax_progress_to_dict(self):
|
||||
"""WeFaxProgress.to_dict() should produce expected format."""
|
||||
from utils.wefax import WeFaxProgress
|
||||
p = WeFaxProgress(
|
||||
status='receiving',
|
||||
station='NOJ',
|
||||
message='Receiving: 100 lines',
|
||||
progress_percent=50,
|
||||
line_count=100,
|
||||
)
|
||||
d = p.to_dict()
|
||||
assert d['type'] == 'wefax_progress'
|
||||
assert d['status'] == 'receiving'
|
||||
assert d['progress'] == 50
|
||||
assert d['station'] == 'NOJ'
|
||||
assert d['line_count'] == 100
|
||||
|
||||
def test_singleton_returns_same_instance(self, tmp_path):
|
||||
"""get_wefax_decoder() should return a singleton."""
|
||||
from utils.wefax import WeFaxDecoder
|
||||
# Use __new__ to avoid __init__ creating dirs
|
||||
d1 = WeFaxDecoder.__new__(WeFaxDecoder)
|
||||
# Test the module-level singleton pattern
|
||||
import utils.wefax as wefax_mod
|
||||
original = wefax_mod._decoder
|
||||
try:
|
||||
wefax_mod._decoder = d1
|
||||
assert wefax_mod.get_wefax_decoder() is d1
|
||||
assert wefax_mod.get_wefax_decoder() is d1
|
||||
finally:
|
||||
wefax_mod._decoder = original
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWeFaxRoutes:
|
||||
"""WeFax route endpoint tests."""
|
||||
|
||||
def test_status(self, client):
|
||||
"""GET /wefax/status should return decoder status."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.get_images.return_value = []
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.get('/wefax/status')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['available'] is True
|
||||
assert data['running'] is False
|
||||
|
||||
def test_stations_list(self, client):
|
||||
"""GET /wefax/stations should return station list."""
|
||||
_login_session(client)
|
||||
response = client.get('/wefax/stations')
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'ok'
|
||||
assert data['count'] >= 10
|
||||
|
||||
def test_station_detail(self, client):
|
||||
"""GET /wefax/stations/NOJ should return station detail."""
|
||||
_login_session(client)
|
||||
response = client.get('/wefax/stations/NOJ')
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'ok'
|
||||
assert data['station']['callsign'] == 'NOJ'
|
||||
assert 'current_broadcasts' in data
|
||||
|
||||
def test_station_not_found(self, client):
|
||||
"""GET /wefax/stations/XXXXX should return 404."""
|
||||
_login_session(client)
|
||||
response = client.get('/wefax/stations/XXXXX')
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_start_requires_frequency(self, client):
|
||||
"""POST /wefax/start without frequency should fail."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.post(
|
||||
'/wefax/start',
|
||||
data=json.dumps({}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'error'
|
||||
|
||||
def test_start_validates_frequency_range(self, client):
|
||||
"""POST /wefax/start with out-of-range frequency should fail."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.post(
|
||||
'/wefax/start',
|
||||
data=json.dumps({'frequency_khz': 100}), # 0.1 MHz - too low
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_start_validates_ioc(self, client):
|
||||
"""POST /wefax/start with invalid IOC should fail."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.post(
|
||||
'/wefax/start',
|
||||
data=json.dumps({'frequency_khz': 4298, 'ioc': 999}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert 'IOC' in data['message']
|
||||
|
||||
def test_start_validates_lpm(self, client):
|
||||
"""POST /wefax/start with invalid LPM should fail."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.post(
|
||||
'/wefax/start',
|
||||
data=json.dumps({'frequency_khz': 4298, 'lpm': 999}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert 'LPM' in data['message']
|
||||
|
||||
def test_start_success(self, client):
|
||||
"""POST /wefax/start with valid params should succeed."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = True
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
|
||||
patch('routes.wefax.app_module.claim_sdr_device', return_value=None):
|
||||
response = client.post(
|
||||
'/wefax/start',
|
||||
data=json.dumps({
|
||||
'frequency_khz': 4298,
|
||||
'station': 'NOJ',
|
||||
'device': 0,
|
||||
'ioc': 576,
|
||||
'lpm': 120,
|
||||
}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'started'
|
||||
assert data['frequency_khz'] == 4298
|
||||
assert data['usb_offset_applied'] is True
|
||||
assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||
assert data['frequency_reference'] == 'carrier'
|
||||
assert data['station'] == 'NOJ'
|
||||
mock_decoder.start.assert_called_once()
|
||||
start_kwargs = mock_decoder.start.call_args.kwargs
|
||||
assert math.isclose(start_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||
|
||||
def test_start_respects_dial_reference_override(self, client):
|
||||
"""POST /wefax/start with dial reference should not apply USB offset."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = True
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
|
||||
patch('routes.wefax.app_module.claim_sdr_device', return_value=None):
|
||||
response = client.post(
|
||||
'/wefax/start',
|
||||
data=json.dumps({
|
||||
'frequency_khz': 4298,
|
||||
'station': 'NOJ',
|
||||
'device': 0,
|
||||
'frequency_reference': 'dial',
|
||||
}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'started'
|
||||
assert data['usb_offset_applied'] is False
|
||||
assert math.isclose(data['tuned_frequency_khz'], 4298.0, abs_tol=1e-6)
|
||||
assert data['frequency_reference'] == 'dial'
|
||||
start_kwargs = mock_decoder.start.call_args.kwargs
|
||||
assert math.isclose(start_kwargs['frequency_khz'], 4298.0, abs_tol=1e-6)
|
||||
|
||||
def test_start_device_busy(self, client):
|
||||
"""POST /wefax/start should return 409 when device is busy."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
|
||||
patch('routes.wefax.app_module.claim_sdr_device',
|
||||
return_value='Device 0 in use by pager'):
|
||||
response = client.post(
|
||||
'/wefax/start',
|
||||
data=json.dumps({'frequency_khz': 4298}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
data = response.get_json()
|
||||
assert data['error_type'] == 'DEVICE_BUSY'
|
||||
|
||||
def test_stop(self, client):
|
||||
"""POST /wefax/stop should stop the decoder."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.post('/wefax/stop')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'stopped'
|
||||
mock_decoder.stop.assert_called_once()
|
||||
|
||||
def test_images_list(self, client):
|
||||
"""GET /wefax/images should return image list."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.get_images.return_value = []
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.get('/wefax/images')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'ok'
|
||||
assert data['count'] == 0
|
||||
|
||||
def test_delete_image_invalid_filename(self, client):
|
||||
"""DELETE /wefax/images/<filename> should reject invalid filenames."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
# Use a filename with special chars that won't be split by Flask routing
|
||||
response = client.delete('/wefax/images/te$t!file.png')
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_delete_image_wrong_extension(self, client):
|
||||
"""DELETE /wefax/images/<filename> should reject non-PNG."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.delete('/wefax/images/test.jpg')
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_schedule_enable_applies_usb_alignment(self, client):
|
||||
"""Scheduler should receive tuned USB dial frequency in auto mode."""
|
||||
_login_session(client)
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.enable.return_value = {
|
||||
'enabled': True,
|
||||
'scheduled_count': 2,
|
||||
'total_broadcasts': 2,
|
||||
}
|
||||
|
||||
with patch('utils.wefax_scheduler.get_wefax_scheduler', return_value=mock_scheduler):
|
||||
response = client.post(
|
||||
'/wefax/schedule/enable',
|
||||
data=json.dumps({
|
||||
'station': 'NOJ',
|
||||
'frequency_khz': 4298,
|
||||
'device': 0,
|
||||
}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'ok'
|
||||
assert data['usb_offset_applied'] is True
|
||||
assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||
enable_kwargs = mock_scheduler.enable.call_args.kwargs
|
||||
assert math.isclose(enable_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||
|
||||
|
||||
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()
|
||||
+17
-21
@@ -14,30 +14,26 @@ from __future__ import annotations
|
||||
# =============================================================================
|
||||
|
||||
FORMAT_CODES = {
|
||||
100: 'DISTRESS', # All ships distress alert
|
||||
102: 'ALL_SHIPS', # All ships call
|
||||
104: 'GROUP', # Group call
|
||||
106: 'DISTRESS_ACK', # Distress acknowledgement
|
||||
108: 'DISTRESS_RELAY', # Distress relay
|
||||
110: 'GEOGRAPHIC', # Geographic area call
|
||||
112: 'INDIVIDUAL', # Individual call
|
||||
114: 'INDIVIDUAL_ACK', # Individual acknowledgement
|
||||
116: 'ROUTINE', # Routine call
|
||||
118: 'SAFETY', # Safety call
|
||||
120: 'URGENCY', # Urgency call
|
||||
102: 'ALL_SHIPS', # All ships call
|
||||
112: 'INDIVIDUAL', # Individual call
|
||||
114: 'INDIVIDUAL_ACK', # Individual acknowledgement
|
||||
116: 'GROUP', # Group call (including geographic area)
|
||||
120: 'DISTRESS', # Distress alert
|
||||
123: 'ALL_SHIPS_URGENCY_SAFETY', # All ships urgency/safety
|
||||
}
|
||||
|
||||
# Valid ITU-R M.493 format specifiers
|
||||
VALID_FORMAT_SPECIFIERS = {102, 112, 114, 116, 120, 123}
|
||||
|
||||
# Valid EOS (End of Sequence) symbols per ITU-R M.493
|
||||
VALID_EOS = {117, 122, 127}
|
||||
|
||||
# Category priority (lower = higher priority)
|
||||
CATEGORY_PRIORITY = {
|
||||
'DISTRESS': 0,
|
||||
'DISTRESS_ACK': 1,
|
||||
'DISTRESS_RELAY': 2,
|
||||
'URGENCY': 3,
|
||||
'SAFETY': 4,
|
||||
'ROUTINE': 5,
|
||||
'ALL_SHIPS_URGENCY_SAFETY': 2,
|
||||
'ALL_SHIPS': 5,
|
||||
'GROUP': 5,
|
||||
'GEOGRAPHIC': 5,
|
||||
'INDIVIDUAL': 5,
|
||||
'INDIVIDUAL_ACK': 5,
|
||||
}
|
||||
@@ -453,11 +449,11 @@ VHF_CHANNELS = {
|
||||
# DSC Modulation Parameters
|
||||
# =============================================================================
|
||||
|
||||
DSC_BAUD_RATE = 100 # 100 baud per ITU-R M.493
|
||||
DSC_BAUD_RATE = 1200 # 1200 bps per ITU-R M.493
|
||||
|
||||
# FSK tone frequencies (Hz)
|
||||
DSC_MARK_FREQ = 1800 # B (mark) - binary 1
|
||||
DSC_SPACE_FREQ = 1200 # Y (space) - binary 0
|
||||
# FSK tone frequencies (Hz) on 1700 Hz subcarrier
|
||||
DSC_MARK_FREQ = 2100 # B (mark) - binary 1
|
||||
DSC_SPACE_FREQ = 1300 # Y (space) - binary 0
|
||||
|
||||
# Audio sample rate for decoding
|
||||
DSC_AUDIO_SAMPLE_RATE = 48000
|
||||
|
||||
+23
-21
@@ -5,9 +5,9 @@ DSC (Digital Selective Calling) decoder.
|
||||
Decodes VHF DSC signals per ITU-R M.493. Reads 48kHz 16-bit signed
|
||||
audio from stdin (from rtl_fm) and outputs JSON messages to stdout.
|
||||
|
||||
DSC uses 100 baud FSK with:
|
||||
- Mark (1): 1800 Hz
|
||||
- Space (0): 1200 Hz
|
||||
DSC uses 1200 bps FSK on a 1700 Hz subcarrier with:
|
||||
- Mark (1): 2100 Hz
|
||||
- Space (0): 1300 Hz
|
||||
|
||||
Frame structure:
|
||||
1. Dot pattern: 200 bits alternating 1/0 for synchronization
|
||||
@@ -42,6 +42,7 @@ from .constants import (
|
||||
DSC_AUDIO_SAMPLE_RATE,
|
||||
FORMAT_CODES,
|
||||
DISTRESS_NATURE_CODES,
|
||||
VALID_EOS,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
@@ -57,7 +58,7 @@ class DSCDecoder:
|
||||
"""
|
||||
DSC FSK decoder.
|
||||
|
||||
Demodulates 100 baud FSK audio and decodes DSC protocol.
|
||||
Demodulates 1200 bps FSK audio and decodes DSC protocol.
|
||||
"""
|
||||
|
||||
def __init__(self, sample_rate: int = DSC_AUDIO_SAMPLE_RATE):
|
||||
@@ -66,13 +67,13 @@ class DSCDecoder:
|
||||
self.samples_per_bit = sample_rate // self.baud_rate
|
||||
|
||||
# FSK frequencies
|
||||
self.mark_freq = DSC_MARK_FREQ # 1800 Hz = binary 1
|
||||
self.space_freq = DSC_SPACE_FREQ # 1200 Hz = binary 0
|
||||
self.mark_freq = DSC_MARK_FREQ # 2100 Hz = binary 1
|
||||
self.space_freq = DSC_SPACE_FREQ # 1300 Hz = binary 0
|
||||
|
||||
# Bandpass filter for DSC band (1100-1900 Hz)
|
||||
# Bandpass filter for DSC band (1100-2300 Hz)
|
||||
nyq = sample_rate / 2
|
||||
low = 1100 / nyq
|
||||
high = 1900 / nyq
|
||||
high = 2300 / nyq
|
||||
self.bp_b, self.bp_a = scipy_signal.butter(4, [low, high], btype='band')
|
||||
|
||||
# Build FSK correlators
|
||||
@@ -278,11 +279,11 @@ class DSCDecoder:
|
||||
if len(symbols) < 5:
|
||||
return None
|
||||
|
||||
# Look for EOS (End of Sequence) - symbol 127
|
||||
# Look for EOS (End of Sequence) - symbols 117, 122, or 127
|
||||
eos_found = False
|
||||
eos_index = -1
|
||||
for i, sym in enumerate(symbols):
|
||||
if sym == 127: # EOS symbol
|
||||
if sym in VALID_EOS:
|
||||
eos_found = True
|
||||
eos_index = i
|
||||
break
|
||||
@@ -337,20 +338,21 @@ class DSCDecoder:
|
||||
format_code = symbols[0]
|
||||
format_text = FORMAT_CODES.get(format_code, f'UNKNOWN-{format_code}')
|
||||
|
||||
# Determine category from format
|
||||
category = 'ROUTINE'
|
||||
if format_code == 100:
|
||||
# Derive category from format specifier per ITU-R M.493
|
||||
if format_code == 120:
|
||||
category = 'DISTRESS'
|
||||
elif format_code == 106:
|
||||
category = 'DISTRESS_ACK'
|
||||
elif format_code == 108:
|
||||
category = 'DISTRESS_RELAY'
|
||||
elif format_code == 118:
|
||||
category = 'SAFETY'
|
||||
elif format_code == 120:
|
||||
category = 'URGENCY'
|
||||
elif format_code == 123:
|
||||
category = 'ALL_SHIPS_URGENCY_SAFETY'
|
||||
elif format_code == 102:
|
||||
category = 'ALL_SHIPS'
|
||||
elif format_code == 116:
|
||||
category = 'GROUP'
|
||||
elif format_code == 112:
|
||||
category = 'INDIVIDUAL'
|
||||
elif format_code == 114:
|
||||
category = 'INDIVIDUAL_ACK'
|
||||
else:
|
||||
category = FORMAT_CODES.get(format_code, 'UNKNOWN')
|
||||
|
||||
# Decode MMSI from symbols 1-5 (destination/address)
|
||||
dest_mmsi = self._decode_mmsi(symbols[1:6])
|
||||
|
||||
+60
-9
@@ -19,6 +19,8 @@ from .constants import (
|
||||
TELECOMMAND_CODES,
|
||||
CATEGORY_PRIORITY,
|
||||
MID_COUNTRY_MAP,
|
||||
VALID_FORMAT_SPECIFIERS,
|
||||
VALID_EOS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('intercept.dsc.parser')
|
||||
@@ -139,13 +141,62 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None:
|
||||
if 'source_mmsi' not in data:
|
||||
return None
|
||||
|
||||
# ITU-R M.493 validation: format specifier must be valid
|
||||
format_code = data.get('format')
|
||||
if format_code not in VALID_FORMAT_SPECIFIERS:
|
||||
logger.debug(f"Rejected DSC: invalid format specifier {format_code}")
|
||||
return None
|
||||
|
||||
# Validate MMSIs
|
||||
source_mmsi = str(data.get('source_mmsi', ''))
|
||||
if not validate_mmsi(source_mmsi):
|
||||
logger.debug(f"Rejected DSC: invalid source MMSI {source_mmsi}")
|
||||
return None
|
||||
|
||||
dest_mmsi_val = data.get('dest_mmsi')
|
||||
if dest_mmsi_val is not None:
|
||||
dest_mmsi_str = str(dest_mmsi_val)
|
||||
if not validate_mmsi(dest_mmsi_str):
|
||||
logger.debug(f"Rejected DSC: invalid dest MMSI {dest_mmsi_str}")
|
||||
return None
|
||||
|
||||
# Validate raw field structure if present
|
||||
raw = data.get('raw')
|
||||
if raw is not None:
|
||||
raw_str = str(raw)
|
||||
if not re.match(r'^\d+$', raw_str):
|
||||
logger.debug("Rejected DSC: raw field contains non-digits")
|
||||
return None
|
||||
if len(raw_str) % 3 != 0:
|
||||
logger.debug("Rejected DSC: raw field length not divisible by 3")
|
||||
return None
|
||||
# Last 3-digit token must be a valid EOS symbol
|
||||
if len(raw_str) >= 3:
|
||||
last_token = int(raw_str[-3:])
|
||||
if last_token not in VALID_EOS:
|
||||
logger.debug(f"Rejected DSC: raw EOS token {last_token} not valid")
|
||||
return None
|
||||
|
||||
# Validate telecommand values if present (must be 100-127)
|
||||
for tc_field in ('telecommand1', 'telecommand2'):
|
||||
tc_val = data.get(tc_field)
|
||||
if tc_val is not None:
|
||||
try:
|
||||
tc_int = int(tc_val)
|
||||
except (ValueError, TypeError):
|
||||
logger.debug(f"Rejected DSC: invalid {tc_field} value {tc_val}")
|
||||
return None
|
||||
if tc_int < 100 or tc_int > 127:
|
||||
logger.debug(f"Rejected DSC: {tc_field} {tc_int} out of range 100-127")
|
||||
return None
|
||||
|
||||
# Build parsed message
|
||||
msg = {
|
||||
'type': 'dsc_message',
|
||||
'source_mmsi': str(data.get('source_mmsi', '')),
|
||||
'dest_mmsi': str(data.get('dest_mmsi', '')) if data.get('dest_mmsi') else None,
|
||||
'format_code': data.get('format'),
|
||||
'format_text': get_format_text(data.get('format', 0)),
|
||||
'source_mmsi': source_mmsi,
|
||||
'dest_mmsi': str(data.get('dest_mmsi', '')) if data.get('dest_mmsi') is not None else None,
|
||||
'format_code': format_code,
|
||||
'format_text': get_format_text(format_code),
|
||||
'category': data.get('category', 'UNKNOWN').upper(),
|
||||
'timestamp': data.get('timestamp') or datetime.utcnow().isoformat(),
|
||||
}
|
||||
@@ -156,7 +207,7 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None:
|
||||
msg['source_country'] = country
|
||||
|
||||
# Add distress nature if present
|
||||
if 'nature' in data and data['nature']:
|
||||
if data.get('nature') is not None:
|
||||
msg['nature_code'] = data['nature']
|
||||
msg['nature_of_distress'] = get_distress_nature_text(data['nature'])
|
||||
|
||||
@@ -173,16 +224,16 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None:
|
||||
pass
|
||||
|
||||
# Add telecommand info
|
||||
if 'telecommand1' in data and data['telecommand1']:
|
||||
if data.get('telecommand1') is not None:
|
||||
msg['telecommand1'] = data['telecommand1']
|
||||
msg['telecommand1_text'] = get_telecommand_text(data['telecommand1'])
|
||||
|
||||
if 'telecommand2' in data and data['telecommand2']:
|
||||
if data.get('telecommand2') is not None:
|
||||
msg['telecommand2'] = data['telecommand2']
|
||||
msg['telecommand2_text'] = get_telecommand_text(data['telecommand2'])
|
||||
|
||||
# Add channel if present
|
||||
if 'channel' in data and data['channel']:
|
||||
if data.get('channel') is not None:
|
||||
msg['channel'] = data['channel']
|
||||
|
||||
# Add EOS (End of Sequence) info
|
||||
@@ -197,7 +248,7 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None:
|
||||
msg['priority'] = get_category_priority(msg['category'])
|
||||
|
||||
# Mark if this is a critical alert
|
||||
msg['is_critical'] = msg['category'] in ('DISTRESS', 'DISTRESS_ACK', 'DISTRESS_RELAY', 'URGENCY')
|
||||
msg['is_critical'] = msg['category'] in ('DISTRESS', 'ALL_SHIPS_URGENCY_SAFETY')
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
+8
-6
@@ -194,18 +194,20 @@ class GPSDClient:
|
||||
"""Return gpsd connection info."""
|
||||
return f"gpsd://{self.host}:{self.port}"
|
||||
|
||||
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
|
||||
"""Add a callback to be called on position updates."""
|
||||
self._callbacks.append(callback)
|
||||
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
|
||||
"""Add a callback to be called on position updates."""
|
||||
if callback not in self._callbacks:
|
||||
self._callbacks.append(callback)
|
||||
|
||||
def remove_callback(self, callback: Callable[[GPSPosition], None]) -> None:
|
||||
"""Remove a position update callback."""
|
||||
if callback in self._callbacks:
|
||||
self._callbacks.remove(callback)
|
||||
|
||||
def add_sky_callback(self, callback: Callable[[GPSSkyData], None]) -> None:
|
||||
"""Add a callback to be called on sky data updates."""
|
||||
self._sky_callbacks.append(callback)
|
||||
def add_sky_callback(self, callback: Callable[[GPSSkyData], None]) -> None:
|
||||
"""Add a callback to be called on sky data updates."""
|
||||
if callback not in self._sky_callbacks:
|
||||
self._sky_callbacks.append(callback)
|
||||
|
||||
def remove_sky_callback(self, callback: Callable[[GPSSkyData], None]) -> None:
|
||||
"""Remove a sky data update callback."""
|
||||
|
||||
+59
-40
@@ -376,63 +376,82 @@ class MeshtasticClient:
|
||||
self._error = "Meshtastic SDK not installed. Install with: pip install meshtastic"
|
||||
return False
|
||||
|
||||
# Quick check under lock — bail if already running
|
||||
with self._lock:
|
||||
if self._running:
|
||||
return True
|
||||
|
||||
try:
|
||||
# Subscribe to message events before connecting
|
||||
pub.subscribe(self._on_receive, "meshtastic.receive")
|
||||
pub.subscribe(self._on_connection, "meshtastic.connection.established")
|
||||
pub.subscribe(self._on_disconnect, "meshtastic.connection.lost")
|
||||
# Create interface outside lock (blocking I/O: serial/TCP connect)
|
||||
new_interface = None
|
||||
new_device_path = None
|
||||
new_connection_type = None
|
||||
try:
|
||||
# Subscribe to message events before connecting
|
||||
pub.subscribe(self._on_receive, "meshtastic.receive")
|
||||
pub.subscribe(self._on_connection, "meshtastic.connection.established")
|
||||
pub.subscribe(self._on_disconnect, "meshtastic.connection.lost")
|
||||
|
||||
# Connect based on connection type
|
||||
if connection_type == 'tcp':
|
||||
if not hostname:
|
||||
self._error = "Hostname is required for TCP connections"
|
||||
self._cleanup_subscriptions()
|
||||
return False
|
||||
self._interface = meshtastic.tcp_interface.TCPInterface(hostname=hostname)
|
||||
self._device_path = hostname
|
||||
self._connection_type = 'tcp'
|
||||
logger.info(f"Connected to Meshtastic device via TCP: {hostname}")
|
||||
if connection_type == 'tcp':
|
||||
if not hostname:
|
||||
self._error = "Hostname is required for TCP connections"
|
||||
self._cleanup_subscriptions()
|
||||
return False
|
||||
new_interface = meshtastic.tcp_interface.TCPInterface(hostname=hostname)
|
||||
new_device_path = hostname
|
||||
new_connection_type = 'tcp'
|
||||
logger.info(f"Connected to Meshtastic device via TCP: {hostname}")
|
||||
else:
|
||||
if device:
|
||||
new_interface = meshtastic.serial_interface.SerialInterface(device)
|
||||
new_device_path = device
|
||||
else:
|
||||
# Serial connection (default)
|
||||
if device:
|
||||
self._interface = meshtastic.serial_interface.SerialInterface(device)
|
||||
self._device_path = device
|
||||
else:
|
||||
# Auto-discover
|
||||
self._interface = meshtastic.serial_interface.SerialInterface()
|
||||
self._device_path = "auto"
|
||||
self._connection_type = 'serial'
|
||||
logger.info(f"Connected to Meshtastic device via serial: {self._device_path}")
|
||||
new_interface = meshtastic.serial_interface.SerialInterface()
|
||||
new_device_path = "auto"
|
||||
new_connection_type = 'serial'
|
||||
logger.info(f"Connected to Meshtastic device via serial: {new_device_path}")
|
||||
except Exception as e:
|
||||
self._error = str(e)
|
||||
logger.error(f"Failed to connect to Meshtastic: {e}")
|
||||
self._cleanup_subscriptions()
|
||||
return False
|
||||
|
||||
self._running = True
|
||||
self._error = None
|
||||
# Install interface under lock
|
||||
with self._lock:
|
||||
if self._running:
|
||||
# Another thread connected while we were connecting — discard ours
|
||||
if new_interface:
|
||||
try:
|
||||
new_interface.close()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self._error = str(e)
|
||||
logger.error(f"Failed to connect to Meshtastic: {e}")
|
||||
self._cleanup_subscriptions()
|
||||
return False
|
||||
self._interface = new_interface
|
||||
self._device_path = new_device_path
|
||||
self._connection_type = new_connection_type
|
||||
self._running = True
|
||||
self._error = None
|
||||
return True
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from the Meshtastic device."""
|
||||
iface_to_close = None
|
||||
with self._lock:
|
||||
if self._interface:
|
||||
try:
|
||||
self._interface.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing Meshtastic interface: {e}")
|
||||
self._interface = None
|
||||
|
||||
iface_to_close = self._interface
|
||||
self._interface = None
|
||||
self._cleanup_subscriptions()
|
||||
self._running = False
|
||||
self._device_path = None
|
||||
self._connection_type = None
|
||||
logger.info("Disconnected from Meshtastic device")
|
||||
|
||||
# Close interface outside lock (blocking I/O)
|
||||
if iface_to_close:
|
||||
try:
|
||||
iface_to_close.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing Meshtastic interface: {e}")
|
||||
|
||||
logger.info("Disconnected from Meshtastic device")
|
||||
|
||||
def _cleanup_subscriptions(self) -> None:
|
||||
"""Unsubscribe from pubsub topics."""
|
||||
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
"""Morse code (CW) decoder using Goertzel tone detection.
|
||||
|
||||
Signal chain: rtl_fm -M usb → raw PCM → Goertzel filter → timing state machine → characters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import math
|
||||
import queue
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
# International Morse Code table
|
||||
MORSE_TABLE: dict[str, str] = {
|
||||
'.-': 'A', '-...': 'B', '-.-.': 'C', '-..': 'D', '.': 'E',
|
||||
'..-.': 'F', '--.': 'G', '....': 'H', '..': 'I', '.---': 'J',
|
||||
'-.-': 'K', '.-..': 'L', '--': 'M', '-.': 'N', '---': 'O',
|
||||
'.--.': 'P', '--.-': 'Q', '.-.': 'R', '...': 'S', '-': 'T',
|
||||
'..-': 'U', '...-': 'V', '.--': 'W', '-..-': 'X', '-.--': 'Y',
|
||||
'--..': 'Z',
|
||||
'-----': '0', '.----': '1', '..---': '2', '...--': '3',
|
||||
'....-': '4', '.....': '5', '-....': '6', '--...': '7',
|
||||
'---..': '8', '----.': '9',
|
||||
'.-.-.-': '.', '--..--': ',', '..--..': '?', '.----.': "'",
|
||||
'-.-.--': '!', '-..-.': '/', '-.--.': '(', '-.--.-': ')',
|
||||
'.-...': '&', '---...': ':', '-.-.-.': ';', '-...-': '=',
|
||||
'.-.-.': '+', '-....-': '-', '..--.-': '_', '.-..-.': '"',
|
||||
'...-..-': '$', '.--.-.': '@',
|
||||
# Prosigns (unique codes only; -...- and -.--.- already mapped above)
|
||||
'-.-.-': '<CT>', '.-.-': '<AA>', '...-.-': '<SK>',
|
||||
}
|
||||
|
||||
# Reverse lookup: character → morse notation
|
||||
CHAR_TO_MORSE: dict[str, str] = {v: k for k, v in MORSE_TABLE.items()}
|
||||
|
||||
|
||||
class GoertzelFilter:
|
||||
"""Single-frequency tone detector using the Goertzel algorithm.
|
||||
|
||||
O(N) per block, much cheaper than FFT for detecting one frequency.
|
||||
"""
|
||||
|
||||
def __init__(self, target_freq: float, sample_rate: int, block_size: int):
|
||||
self.target_freq = target_freq
|
||||
self.sample_rate = sample_rate
|
||||
self.block_size = block_size
|
||||
# Precompute coefficient
|
||||
k = round(target_freq * block_size / sample_rate)
|
||||
omega = 2.0 * math.pi * k / block_size
|
||||
self.coeff = 2.0 * math.cos(omega)
|
||||
|
||||
def magnitude(self, samples: list[float] | tuple[float, ...]) -> float:
|
||||
"""Compute magnitude of the target frequency in the sample block."""
|
||||
s0 = 0.0
|
||||
s1 = 0.0
|
||||
s2 = 0.0
|
||||
coeff = self.coeff
|
||||
for sample in samples:
|
||||
s0 = sample + coeff * s1 - s2
|
||||
s2 = s1
|
||||
s1 = s0
|
||||
return math.sqrt(s1 * s1 + s2 * s2 - coeff * s1 * s2)
|
||||
|
||||
|
||||
class MorseDecoder:
|
||||
"""Real-time Morse decoder with adaptive threshold.
|
||||
|
||||
Processes blocks of PCM audio and emits decoded characters.
|
||||
Timing based on PARIS standard: dit = 1.2/WPM seconds.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sample_rate: int = 8000,
|
||||
tone_freq: float = 700.0,
|
||||
wpm: int = 15,
|
||||
):
|
||||
self.sample_rate = sample_rate
|
||||
self.tone_freq = tone_freq
|
||||
self.wpm = wpm
|
||||
|
||||
# Goertzel filter: ~50 blocks/sec at 8kHz
|
||||
self._block_size = sample_rate // 50
|
||||
self._filter = GoertzelFilter(tone_freq, sample_rate, self._block_size)
|
||||
self._block_duration = self._block_size / sample_rate # seconds per block
|
||||
|
||||
# Timing thresholds (in blocks, converted from seconds)
|
||||
dit_sec = 1.2 / wpm
|
||||
self._dah_threshold = 2.0 * dit_sec / self._block_duration # blocks
|
||||
self._dit_min = 0.3 * dit_sec / self._block_duration # min blocks for dit
|
||||
self._char_gap = 3.0 * dit_sec / self._block_duration # blocks
|
||||
self._word_gap = 7.0 * dit_sec / self._block_duration # blocks
|
||||
|
||||
# Adaptive threshold via EMA
|
||||
self._noise_floor = 0.0
|
||||
self._signal_peak = 0.0
|
||||
self._threshold = 0.0
|
||||
self._ema_alpha = 0.1 # smoothing factor
|
||||
|
||||
# State machine (counts in blocks, not wall-clock time)
|
||||
self._tone_on = False
|
||||
self._tone_blocks = 0 # blocks since tone started
|
||||
self._silence_blocks = 0 # blocks since silence started
|
||||
self._current_symbol = '' # accumulates dits/dahs for current char
|
||||
self._pending_buffer: list[float] = []
|
||||
self._blocks_processed = 0 # total blocks for warm-up tracking
|
||||
|
||||
def process_block(self, pcm_bytes: bytes) -> list[dict[str, Any]]:
|
||||
"""Process a chunk of 16-bit LE PCM and return decoded events.
|
||||
|
||||
Returns list of event dicts with keys:
|
||||
type: 'scope' | 'morse_char' | 'morse_space'
|
||||
+ type-specific fields
|
||||
"""
|
||||
events: list[dict[str, Any]] = []
|
||||
|
||||
# Unpack PCM samples
|
||||
n_samples = len(pcm_bytes) // 2
|
||||
if n_samples == 0:
|
||||
return events
|
||||
|
||||
samples = struct.unpack(f'<{n_samples}h', pcm_bytes[:n_samples * 2])
|
||||
|
||||
# Feed samples into pending buffer and process in blocks
|
||||
self._pending_buffer.extend(samples)
|
||||
|
||||
amplitudes: list[float] = []
|
||||
|
||||
while len(self._pending_buffer) >= self._block_size:
|
||||
block = self._pending_buffer[:self._block_size]
|
||||
self._pending_buffer = self._pending_buffer[self._block_size:]
|
||||
|
||||
# Normalize to [-1, 1]
|
||||
normalized = [s / 32768.0 for s in block]
|
||||
mag = self._filter.magnitude(normalized)
|
||||
amplitudes.append(mag)
|
||||
|
||||
self._blocks_processed += 1
|
||||
|
||||
# Update adaptive threshold
|
||||
if mag < self._threshold or self._threshold == 0:
|
||||
self._noise_floor += self._ema_alpha * (mag - self._noise_floor)
|
||||
else:
|
||||
self._signal_peak += self._ema_alpha * (mag - self._signal_peak)
|
||||
|
||||
self._threshold = (self._noise_floor + self._signal_peak) / 2.0
|
||||
|
||||
tone_detected = mag > self._threshold and self._threshold > 0
|
||||
|
||||
if tone_detected and not self._tone_on:
|
||||
# Tone just started - check silence duration for gaps
|
||||
self._tone_on = True
|
||||
silence_count = self._silence_blocks
|
||||
self._tone_blocks = 0
|
||||
|
||||
if self._current_symbol and silence_count >= self._char_gap:
|
||||
# Character gap - decode accumulated symbol
|
||||
char = MORSE_TABLE.get(self._current_symbol)
|
||||
if char:
|
||||
events.append({
|
||||
'type': 'morse_char',
|
||||
'char': char,
|
||||
'morse': self._current_symbol,
|
||||
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
||||
})
|
||||
|
||||
if silence_count >= self._word_gap:
|
||||
events.append({
|
||||
'type': 'morse_space',
|
||||
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
||||
})
|
||||
|
||||
self._current_symbol = ''
|
||||
|
||||
elif not tone_detected and self._tone_on:
|
||||
# Tone just ended - classify as dit or dah
|
||||
self._tone_on = False
|
||||
tone_count = self._tone_blocks
|
||||
self._silence_blocks = 0
|
||||
|
||||
if tone_count >= self._dah_threshold:
|
||||
self._current_symbol += '-'
|
||||
elif tone_count >= self._dit_min:
|
||||
self._current_symbol += '.'
|
||||
|
||||
elif tone_detected and self._tone_on:
|
||||
self._tone_blocks += 1
|
||||
|
||||
elif not tone_detected and not self._tone_on:
|
||||
self._silence_blocks += 1
|
||||
|
||||
# Emit scope data for visualization (~10 Hz is handled by caller)
|
||||
if amplitudes:
|
||||
events.append({
|
||||
'type': 'scope',
|
||||
'amplitudes': amplitudes,
|
||||
'threshold': self._threshold,
|
||||
'tone_on': self._tone_on,
|
||||
})
|
||||
|
||||
return events
|
||||
|
||||
def flush(self) -> list[dict[str, Any]]:
|
||||
"""Flush any pending symbol at end of stream."""
|
||||
events: list[dict[str, Any]] = []
|
||||
if self._current_symbol:
|
||||
char = MORSE_TABLE.get(self._current_symbol)
|
||||
if char:
|
||||
events.append({
|
||||
'type': 'morse_char',
|
||||
'char': char,
|
||||
'morse': self._current_symbol,
|
||||
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
||||
})
|
||||
self._current_symbol = ''
|
||||
return events
|
||||
|
||||
|
||||
def morse_decoder_thread(
|
||||
rtl_stdout,
|
||||
output_queue: queue.Queue,
|
||||
stop_event: threading.Event,
|
||||
sample_rate: int = 8000,
|
||||
tone_freq: float = 700.0,
|
||||
wpm: int = 15,
|
||||
) -> None:
|
||||
"""Thread function: reads PCM from rtl_fm, decodes Morse, pushes to queue.
|
||||
|
||||
Reads raw 16-bit LE PCM from *rtl_stdout* and feeds it through the
|
||||
MorseDecoder, pushing scope and character events onto *output_queue*.
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger('intercept.morse')
|
||||
|
||||
CHUNK = 4096 # bytes per read (2048 samples at 16-bit mono)
|
||||
SCOPE_INTERVAL = 0.1 # scope updates at ~10 Hz
|
||||
last_scope = time.monotonic()
|
||||
|
||||
decoder = MorseDecoder(
|
||||
sample_rate=sample_rate,
|
||||
tone_freq=tone_freq,
|
||||
wpm=wpm,
|
||||
)
|
||||
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
data = rtl_stdout.read(CHUNK)
|
||||
if not data:
|
||||
break
|
||||
|
||||
events = decoder.process_block(data)
|
||||
|
||||
for event in events:
|
||||
if event['type'] == 'scope':
|
||||
# Throttle scope events to ~10 Hz
|
||||
now = time.monotonic()
|
||||
if now - last_scope >= SCOPE_INTERVAL:
|
||||
last_scope = now
|
||||
with contextlib.suppress(queue.Full):
|
||||
output_queue.put_nowait(event)
|
||||
else:
|
||||
# Character and space events always go through
|
||||
with contextlib.suppress(queue.Full):
|
||||
output_queue.put_nowait(event)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Morse decoder thread error: {e}")
|
||||
finally:
|
||||
# Flush any pending symbol
|
||||
for event in decoder.flush():
|
||||
with contextlib.suppress(queue.Full):
|
||||
output_queue.put_nowait(event)
|
||||
@@ -112,6 +112,8 @@ class ProcessMonitor:
|
||||
|
||||
def _check_all_processes(self) -> None:
|
||||
"""Check health of all registered processes."""
|
||||
# Collect crashed processes under lock, handle restarts outside
|
||||
crashed: list[tuple[str, ProcessInfo]] = []
|
||||
with self._lock:
|
||||
for name, info in list(self.processes.items()):
|
||||
if not info.enabled:
|
||||
@@ -126,10 +128,14 @@ class ProcessMonitor:
|
||||
logger.warning(
|
||||
f"Process '{name}' terminated with code {return_code}"
|
||||
)
|
||||
self._handle_crash(name, info)
|
||||
crashed.append((name, info))
|
||||
|
||||
# Handle restarts outside lock (involves sleeps and callbacks)
|
||||
for name, info in crashed:
|
||||
self._handle_crash(name, info)
|
||||
|
||||
def _handle_crash(self, name: str, info: ProcessInfo) -> None:
|
||||
"""Handle a crashed process."""
|
||||
"""Handle a crashed process. Must be called WITHOUT holding self._lock."""
|
||||
if info.restart_callback is None:
|
||||
logger.info(f"No restart callback for '{name}', skipping auto-restart")
|
||||
return
|
||||
@@ -139,7 +145,8 @@ class ProcessMonitor:
|
||||
f"Process '{name}' exceeded max restarts ({info.max_restarts}), "
|
||||
"disabling auto-restart"
|
||||
)
|
||||
info.enabled = False
|
||||
with self._lock:
|
||||
info.enabled = False
|
||||
return
|
||||
|
||||
# Calculate backoff with exponential increase
|
||||
@@ -149,18 +156,20 @@ class ProcessMonitor:
|
||||
f"(attempt {info.restart_count + 1}/{info.max_restarts})"
|
||||
)
|
||||
|
||||
# Wait for backoff period
|
||||
# Wait for backoff period outside lock
|
||||
time.sleep(backoff)
|
||||
|
||||
# Attempt restart
|
||||
try:
|
||||
info.restart_callback()
|
||||
info.restart_count += 1
|
||||
info.last_restart = datetime.now()
|
||||
with self._lock:
|
||||
info.restart_count += 1
|
||||
info.last_restart = datetime.now()
|
||||
logger.info(f"Successfully restarted '{name}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restart '{name}': {e}")
|
||||
info.restart_count += 1
|
||||
with self._lock:
|
||||
info.restart_count += 1
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
+6
-2
@@ -10,6 +10,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from utils.dependencies import get_tool_path
|
||||
|
||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||
|
||||
|
||||
@@ -74,8 +76,9 @@ class AirspyCommandBuilder(CommandBuilder):
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
rx_fm_path = get_tool_path('rx_fm') or 'rx_fm'
|
||||
cmd = [
|
||||
'rx_fm',
|
||||
rx_fm_path,
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-M', modulation,
|
||||
@@ -203,8 +206,9 @@ class AirspyCommandBuilder(CommandBuilder):
|
||||
device_str = self._build_device_string(device)
|
||||
freq_hz = int(frequency_mhz * 1e6)
|
||||
|
||||
rx_sdr_path = get_tool_path('rx_sdr') or 'rx_sdr'
|
||||
cmd = [
|
||||
'rx_sdr',
|
||||
rx_sdr_path,
|
||||
'-d', device_str,
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
|
||||
+6
-2
@@ -9,6 +9,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from utils.dependencies import get_tool_path
|
||||
|
||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||
|
||||
|
||||
@@ -70,8 +72,9 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
rx_fm_path = get_tool_path('rx_fm') or 'rx_fm'
|
||||
cmd = [
|
||||
'rx_fm',
|
||||
rx_fm_path,
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-M', modulation,
|
||||
@@ -203,8 +206,9 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
device_str = self._build_device_string(device)
|
||||
freq_hz = int(frequency_mhz * 1e6)
|
||||
|
||||
rx_sdr_path = get_tool_path('rx_sdr') or 'rx_sdr'
|
||||
cmd = [
|
||||
'rx_sdr',
|
||||
rx_sdr_path,
|
||||
'-d', device_str,
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
|
||||
@@ -9,6 +9,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from utils.dependencies import get_tool_path
|
||||
|
||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||
|
||||
|
||||
@@ -52,8 +54,9 @@ class LimeSDRCommandBuilder(CommandBuilder):
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
rx_fm_path = get_tool_path('rx_fm') or 'rx_fm'
|
||||
cmd = [
|
||||
'rx_fm',
|
||||
rx_fm_path,
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-M', modulation,
|
||||
@@ -181,8 +184,9 @@ class LimeSDRCommandBuilder(CommandBuilder):
|
||||
device_str = self._build_device_string(device)
|
||||
freq_hz = int(frequency_mhz * 1e6)
|
||||
|
||||
rx_sdr_path = get_tool_path('rx_sdr') or 'rx_sdr'
|
||||
cmd = [
|
||||
'rx_sdr',
|
||||
rx_sdr_path,
|
||||
'-d', device_str,
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
|
||||
@@ -9,6 +9,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from utils.dependencies import get_tool_path
|
||||
|
||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||
|
||||
|
||||
@@ -52,8 +54,9 @@ class SDRPlayCommandBuilder(CommandBuilder):
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
rx_fm_path = get_tool_path('rx_fm') or 'rx_fm'
|
||||
cmd = [
|
||||
'rx_fm',
|
||||
rx_fm_path,
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-M', modulation,
|
||||
@@ -181,8 +184,9 @@ class SDRPlayCommandBuilder(CommandBuilder):
|
||||
device_str = self._build_device_string(device)
|
||||
freq_hz = int(frequency_mhz * 1e6)
|
||||
|
||||
rx_sdr_path = get_tool_path('rx_sdr') or 'rx_sdr'
|
||||
cmd = [
|
||||
'rx_sdr',
|
||||
rx_sdr_path,
|
||||
'-d', device_str,
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
|
||||
+36
-6
@@ -26,15 +26,33 @@ _fanout_channels_lock = threading.Lock()
|
||||
|
||||
def _run_fanout(channel: _QueueFanoutChannel) -> None:
|
||||
"""Drain source queue and fan out each message to all subscribers."""
|
||||
idle_drain_batch = 512
|
||||
|
||||
while True:
|
||||
with channel.lock:
|
||||
subscribers = tuple(channel.subscribers)
|
||||
|
||||
if not subscribers:
|
||||
# Keep ingest pipelines responsive even if UI clients disconnect:
|
||||
# drain and drop stale backlog while idle so producer threads do
|
||||
# not block on full source queues.
|
||||
drained = 0
|
||||
for _ in range(idle_drain_batch):
|
||||
try:
|
||||
channel.source_queue.get_nowait()
|
||||
drained += 1
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
if drained == 0:
|
||||
time.sleep(channel.source_timeout)
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = channel.source_queue.get(timeout=channel.source_timeout)
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
with channel.lock:
|
||||
subscribers = tuple(channel.subscribers)
|
||||
|
||||
for subscriber in subscribers:
|
||||
try:
|
||||
subscriber.put_nowait(msg)
|
||||
@@ -52,13 +70,24 @@ def _ensure_fanout_channel(
|
||||
source_queue: queue.Queue,
|
||||
source_timeout: float,
|
||||
) -> _QueueFanoutChannel:
|
||||
"""Get/create a fanout channel and ensure distributor thread is running."""
|
||||
"""Get/create a fanout channel."""
|
||||
with _fanout_channels_lock:
|
||||
channel = _fanout_channels.get(channel_key)
|
||||
if channel is None:
|
||||
channel = _QueueFanoutChannel(source_queue=source_queue, source_timeout=source_timeout)
|
||||
_fanout_channels[channel_key] = channel
|
||||
|
||||
if channel.source_queue is not source_queue:
|
||||
# Keep channel in sync if source queue object is replaced.
|
||||
channel.source_queue = source_queue
|
||||
channel.source_timeout = source_timeout
|
||||
|
||||
return channel
|
||||
|
||||
|
||||
def _ensure_distributor_running(channel: _QueueFanoutChannel, channel_key: str) -> None:
|
||||
"""Ensure fanout distributor thread is running for a channel."""
|
||||
with _fanout_channels_lock:
|
||||
if channel.distributor is None or not channel.distributor.is_alive():
|
||||
channel.distributor = threading.Thread(
|
||||
target=_run_fanout,
|
||||
@@ -68,8 +97,6 @@ def _ensure_fanout_channel(
|
||||
)
|
||||
channel.distributor.start()
|
||||
|
||||
return channel
|
||||
|
||||
|
||||
def subscribe_fanout_queue(
|
||||
source_queue: queue.Queue,
|
||||
@@ -89,6 +116,9 @@ def subscribe_fanout_queue(
|
||||
with channel.lock:
|
||||
channel.subscribers.add(subscriber)
|
||||
|
||||
# Start distributor only after subscriber is registered to avoid initial-loss race.
|
||||
_ensure_distributor_running(channel, channel_key)
|
||||
|
||||
def _unsubscribe() -> None:
|
||||
with channel.lock:
|
||||
channel.subscribers.discard(subscriber)
|
||||
|
||||
+58
-36
@@ -552,15 +552,20 @@ class SSTVDecoder:
|
||||
# Clean up if the thread exits while we thought we were running.
|
||||
# This prevents a "ghost running" state where is_running is True
|
||||
# but the thread has already died (e.g. rtl_fm exited).
|
||||
orphan_proc = None
|
||||
with self._lock:
|
||||
was_running = self._running
|
||||
self._running = False
|
||||
if was_running and self._rtl_process:
|
||||
with contextlib.suppress(Exception):
|
||||
self._rtl_process.terminate()
|
||||
self._rtl_process.wait(timeout=2)
|
||||
orphan_proc = self._rtl_process
|
||||
self._rtl_process = None
|
||||
|
||||
# Terminate outside lock to avoid blocking other operations
|
||||
if orphan_proc:
|
||||
with contextlib.suppress(Exception):
|
||||
orphan_proc.terminate()
|
||||
orphan_proc.wait(timeout=2)
|
||||
|
||||
if was_running:
|
||||
logger.warning("Audio decode thread stopped unexpectedly")
|
||||
err_detail = rtl_fm_error.split('\n')[-1] if rtl_fm_error else ''
|
||||
@@ -661,38 +666,52 @@ class SSTVDecoder:
|
||||
|
||||
def _retune_rtl_fm(self, new_freq_hz: int) -> None:
|
||||
"""Retune rtl_fm to a new frequency by restarting the process."""
|
||||
old_proc = None
|
||||
with self._lock:
|
||||
if not self._running:
|
||||
return
|
||||
old_proc = self._rtl_process
|
||||
self._rtl_process = None
|
||||
|
||||
if self._rtl_process:
|
||||
try:
|
||||
self._rtl_process.terminate()
|
||||
self._rtl_process.wait(timeout=2)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
self._rtl_process.kill()
|
||||
# Terminate old process outside lock
|
||||
if old_proc:
|
||||
try:
|
||||
old_proc.terminate()
|
||||
old_proc.wait(timeout=2)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
old_proc.kill()
|
||||
|
||||
rtl_cmd = [
|
||||
'rtl_fm',
|
||||
'-d', str(self._device_index),
|
||||
'-f', str(new_freq_hz),
|
||||
'-M', self._modulation,
|
||||
'-s', str(SAMPLE_RATE),
|
||||
'-r', str(SAMPLE_RATE),
|
||||
'-l', '0',
|
||||
'-'
|
||||
]
|
||||
# Build and start new process outside lock
|
||||
rtl_cmd = [
|
||||
'rtl_fm',
|
||||
'-d', str(self._device_index),
|
||||
'-f', str(new_freq_hz),
|
||||
'-M', self._modulation,
|
||||
'-s', str(SAMPLE_RATE),
|
||||
'-r', str(SAMPLE_RATE),
|
||||
'-l', '0',
|
||||
'-'
|
||||
]
|
||||
|
||||
logger.debug(f"Restarting rtl_fm: {' '.join(rtl_cmd)}")
|
||||
logger.debug(f"Restarting rtl_fm: {' '.join(rtl_cmd)}")
|
||||
|
||||
self._rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
new_proc = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
self._current_tuned_freq_hz = new_freq_hz
|
||||
# Re-acquire lock to install new process
|
||||
with self._lock:
|
||||
if self._running:
|
||||
self._rtl_process = new_proc
|
||||
self._current_tuned_freq_hz = new_freq_hz
|
||||
else:
|
||||
# stop() was called during retune — clean up new process
|
||||
with contextlib.suppress(Exception):
|
||||
new_proc.terminate()
|
||||
new_proc.wait(timeout=2)
|
||||
|
||||
@property
|
||||
def last_doppler_info(self) -> DopplerInfo | None:
|
||||
@@ -706,19 +725,22 @@ class SSTVDecoder:
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop SSTV decoder."""
|
||||
proc_to_terminate = None
|
||||
with self._lock:
|
||||
self._running = False
|
||||
proc_to_terminate = self._rtl_process
|
||||
self._rtl_process = None
|
||||
|
||||
if self._rtl_process:
|
||||
try:
|
||||
self._rtl_process.terminate()
|
||||
self._rtl_process.wait(timeout=5)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
self._rtl_process.kill()
|
||||
self._rtl_process = None
|
||||
# Terminate outside lock to avoid blocking other operations
|
||||
if proc_to_terminate:
|
||||
try:
|
||||
proc_to_terminate.terminate()
|
||||
proc_to_terminate.wait(timeout=5)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
proc_to_terminate.kill()
|
||||
|
||||
logger.info("SSTV decoder stopped")
|
||||
logger.info("SSTV decoder stopped")
|
||||
|
||||
def get_images(self) -> list[SSTVImage]:
|
||||
"""Get list of decoded images."""
|
||||
|
||||
+2225
-2191
File diff suppressed because it is too large
Load Diff
+163
-89
@@ -85,7 +85,11 @@ WEATHER_SATELLITES = {
|
||||
}
|
||||
|
||||
# Default sample rate for weather satellite reception
|
||||
DEFAULT_SAMPLE_RATE = 1000000 # 1 MHz
|
||||
try:
|
||||
from config import WEATHER_SAT_SAMPLE_RATE as _configured_rate
|
||||
DEFAULT_SAMPLE_RATE = _configured_rate
|
||||
except ImportError:
|
||||
DEFAULT_SAMPLE_RATE = 2400000 # 2.4 MHz — minimum for Meteor LRPT
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -169,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 = 0
|
||||
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'
|
||||
@@ -237,7 +241,7 @@ class WeatherSatDecoder:
|
||||
satellite: str,
|
||||
input_file: str | Path,
|
||||
sample_rate: int = DEFAULT_SAMPLE_RATE,
|
||||
) -> bool:
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Start weather satellite decode from a pre-recorded IQ/WAV file.
|
||||
|
||||
No SDR hardware is required — SatDump runs in offline mode.
|
||||
@@ -248,28 +252,30 @@ class WeatherSatDecoder:
|
||||
sample_rate: Sample rate of the recording in Hz
|
||||
|
||||
Returns:
|
||||
True if started successfully
|
||||
Tuple of (success, error_message). error_message is None on success.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._running:
|
||||
return True
|
||||
return True, None
|
||||
|
||||
if not self._decoder:
|
||||
logger.error("No weather satellite decoder available")
|
||||
msg = 'SatDump not installed. Build from source or install via package manager.'
|
||||
self._emit_progress(CaptureProgress(
|
||||
status='error',
|
||||
message='SatDump not installed. Build from source or install via package manager.'
|
||||
message=msg,
|
||||
))
|
||||
return False
|
||||
return False, msg
|
||||
|
||||
sat_info = WEATHER_SATELLITES.get(satellite)
|
||||
if not sat_info:
|
||||
logger.error(f"Unknown satellite: {satellite}")
|
||||
msg = f'Unknown satellite: {satellite}'
|
||||
self._emit_progress(CaptureProgress(
|
||||
status='error',
|
||||
message=f'Unknown satellite: {satellite}'
|
||||
message=msg,
|
||||
))
|
||||
return False
|
||||
return False, msg
|
||||
|
||||
input_path = Path(input_file)
|
||||
|
||||
@@ -279,29 +285,33 @@ class WeatherSatDecoder:
|
||||
resolved = input_path.resolve()
|
||||
if not resolved.is_relative_to(allowed_base):
|
||||
logger.warning(f"Path traversal blocked in start_from_file: {input_file}")
|
||||
msg = 'Input file must be under the data/ directory'
|
||||
self._emit_progress(CaptureProgress(
|
||||
status='error',
|
||||
message='Input file must be under the data/ directory'
|
||||
message=msg,
|
||||
))
|
||||
return False
|
||||
return False, msg
|
||||
except (OSError, ValueError):
|
||||
msg = 'Invalid file path'
|
||||
self._emit_progress(CaptureProgress(
|
||||
status='error',
|
||||
message='Invalid file path'
|
||||
message=msg,
|
||||
))
|
||||
return False
|
||||
return False, msg
|
||||
|
||||
if not input_path.is_file():
|
||||
logger.error(f"Input file not found: {input_file}")
|
||||
msg = 'Input file not found'
|
||||
self._emit_progress(CaptureProgress(
|
||||
status='error',
|
||||
message='Input file not found'
|
||||
message=msg,
|
||||
))
|
||||
return False
|
||||
return False, msg
|
||||
|
||||
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()
|
||||
@@ -326,17 +336,18 @@ class WeatherSatDecoder:
|
||||
capture_phase='decoding',
|
||||
))
|
||||
|
||||
return True
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
self._running = False
|
||||
error_msg = str(e)
|
||||
logger.error(f"Failed to start file decode: {e}")
|
||||
self._emit_progress(CaptureProgress(
|
||||
status='error',
|
||||
satellite=satellite,
|
||||
message=str(e)
|
||||
message=error_msg,
|
||||
))
|
||||
return False
|
||||
return False, error_msg
|
||||
|
||||
def start(
|
||||
self,
|
||||
@@ -345,7 +356,7 @@ class WeatherSatDecoder:
|
||||
gain: float = 40.0,
|
||||
sample_rate: int = DEFAULT_SAMPLE_RATE,
|
||||
bias_t: bool = False,
|
||||
) -> bool:
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Start weather satellite capture and decode.
|
||||
|
||||
Args:
|
||||
@@ -356,28 +367,35 @@ class WeatherSatDecoder:
|
||||
bias_t: Enable bias-T power for LNA
|
||||
|
||||
Returns:
|
||||
True if started successfully
|
||||
Tuple of (success, error_message). error_message is None on success.
|
||||
"""
|
||||
# Validate satellite BEFORE acquiring the lock
|
||||
sat_info = WEATHER_SATELLITES.get(satellite)
|
||||
if not sat_info:
|
||||
logger.error(f"Unknown satellite: {satellite}")
|
||||
msg = f'Unknown satellite: {satellite}'
|
||||
self._emit_progress(CaptureProgress(
|
||||
status='error',
|
||||
message=msg,
|
||||
))
|
||||
return False, msg
|
||||
|
||||
# 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
|
||||
return True, None
|
||||
|
||||
if not self._decoder:
|
||||
logger.error("No weather satellite decoder available")
|
||||
msg = 'SatDump not installed. Build from source or install via package manager.'
|
||||
self._emit_progress(CaptureProgress(
|
||||
status='error',
|
||||
message='SatDump not installed. Build from source or install via package manager.'
|
||||
message=msg,
|
||||
))
|
||||
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
|
||||
return False, msg
|
||||
|
||||
self._current_satellite = satellite
|
||||
self._current_frequency = sat_info['frequency']
|
||||
@@ -389,7 +407,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} "
|
||||
@@ -405,17 +423,18 @@ class WeatherSatDecoder:
|
||||
capture_phase=self._capture_phase,
|
||||
))
|
||||
|
||||
return True
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
self._running = False
|
||||
error_msg = str(e)
|
||||
logger.error(f"Failed to start weather satellite capture: {e}")
|
||||
self._emit_progress(CaptureProgress(
|
||||
status='error',
|
||||
satellite=satellite,
|
||||
message=str(e)
|
||||
message=error_msg,
|
||||
))
|
||||
return False
|
||||
return False, error_msg
|
||||
|
||||
def _start_satdump(
|
||||
self,
|
||||
@@ -424,6 +443,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
|
||||
@@ -434,9 +454,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',
|
||||
@@ -446,9 +466,14 @@ class WeatherSatDecoder:
|
||||
'--samplerate', str(sample_rate),
|
||||
'--frequency', str(freq_hz),
|
||||
'--gain', str(int(gain)),
|
||||
'--source_id', source_id,
|
||||
]
|
||||
|
||||
# Only pass --source_id if we have a real serial number.
|
||||
# When _resolve_device_id returns None (no serial found),
|
||||
# omit the flag so SatDump uses the first available device.
|
||||
if source_id is not None:
|
||||
cmd.extend(['--source_id', source_id])
|
||||
|
||||
if bias_t:
|
||||
cmd.append('--bias')
|
||||
|
||||
@@ -468,36 +493,33 @@ class WeatherSatDecoder:
|
||||
close_fds=True,
|
||||
)
|
||||
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)
|
||||
# Synchronous startup check — catch immediate failures (bad args,
|
||||
# missing device) before returning to the caller.
|
||||
time.sleep(0.5)
|
||||
if self._process.poll() is not None:
|
||||
error_output = self._drain_pty_output(master_fd)
|
||||
if error_output:
|
||||
logger.error(f"SatDump output:\n{error_output}")
|
||||
error_msg = self._extract_error(error_output, self._process.returncode)
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
# Backup async check for slower failures (e.g. device opens then
|
||||
# fails after a second or two).
|
||||
def _check_early_exit():
|
||||
"""Poll once after 3s; if SatDump died, emit an error event."""
|
||||
time.sleep(3)
|
||||
"""Poll once after 2s; if SatDump died, emit an error event."""
|
||||
time.sleep(2)
|
||||
process = self._process
|
||||
if process is None or process.poll() is None:
|
||||
return # still running or already cleaned up
|
||||
retcode = process.returncode
|
||||
output = b''
|
||||
try:
|
||||
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
|
||||
output_str = output.decode('utf-8', errors='replace')
|
||||
error_msg = f"SatDump exited immediately (code {retcode})"
|
||||
if output_str:
|
||||
for line in output_str.strip().splitlines():
|
||||
if 'error' in line.lower() or 'could not' in line.lower() or 'cannot' in line.lower():
|
||||
error_msg = line.strip()
|
||||
break
|
||||
logger.error(f"SatDump output:\n{output_str}")
|
||||
error_output = self._drain_pty_output(master_fd)
|
||||
if error_output:
|
||||
logger.error(f"SatDump output:\n{error_output}")
|
||||
error_msg = self._extract_error(error_output, process.returncode)
|
||||
self._emit_progress(CaptureProgress(
|
||||
status='error',
|
||||
satellite=self._current_satellite,
|
||||
@@ -568,11 +590,21 @@ class WeatherSatDecoder:
|
||||
close_fds=True,
|
||||
)
|
||||
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
|
||||
# may complete very quickly and exit code 0 is normal success.
|
||||
# The reader thread will handle output and detect errors.
|
||||
# Synchronous startup check — catch immediate failures (bad args,
|
||||
# missing pipeline). For offline mode, exit code 0 is normal success
|
||||
# (file decoding can finish quickly), so only raise on non-zero.
|
||||
time.sleep(0.5)
|
||||
if self._process.poll() is not None and self._process.returncode != 0:
|
||||
error_output = self._drain_pty_output(master_fd)
|
||||
if error_output:
|
||||
logger.error(f"SatDump offline output:\n{error_output}")
|
||||
error_msg = self._extract_error(error_output, self._process.returncode)
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
# Start reader thread to monitor output
|
||||
self._reader_thread = threading.Thread(
|
||||
@@ -605,12 +637,12 @@ class WeatherSatDecoder:
|
||||
return 'info'
|
||||
|
||||
@staticmethod
|
||||
def _resolve_device_id(device_index: int) -> str:
|
||||
def _resolve_device_id(device_index: int) -> str | None:
|
||||
"""Resolve RTL-SDR device index to serial number string for SatDump v1.2+.
|
||||
|
||||
SatDump v1.2+ expects --source_id as a device serial string, not a
|
||||
numeric index. Try to look up the serial via rtl_test, fall back to
|
||||
the string representation of the index.
|
||||
numeric index. Try to look up the serial via rtl_test, return None
|
||||
if no serial can be found (caller should omit --source_id).
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -636,8 +668,35 @@ class WeatherSatDecoder:
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
||||
logger.debug(f"Could not detect device serial: {e}")
|
||||
|
||||
# Fall back to string index
|
||||
return str(device_index)
|
||||
# No serial found — caller should omit --source_id
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _drain_pty_output(master_fd: int) -> str:
|
||||
"""Read all available output from a PTY master fd."""
|
||||
output = b''
|
||||
try:
|
||||
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
|
||||
return output.decode('utf-8', errors='replace')
|
||||
|
||||
@staticmethod
|
||||
def _extract_error(output: str, returncode: int) -> str:
|
||||
"""Extract a meaningful error message from SatDump output."""
|
||||
if output:
|
||||
for line in output.strip().splitlines():
|
||||
lower = line.lower()
|
||||
if 'error' in lower or 'could not' in lower or 'cannot' in lower or 'failed' in lower:
|
||||
return line.strip()
|
||||
return f"SatDump exited immediately (code {returncode})"
|
||||
|
||||
def _read_pty_lines(self):
|
||||
"""Read lines from the PTY master fd, splitting on \\n and \\r.
|
||||
@@ -801,20 +860,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(
|
||||
@@ -892,7 +954,15 @@ class WeatherSatDecoder:
|
||||
product = self._parse_product_name(filepath)
|
||||
|
||||
# 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'
|
||||
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)
|
||||
@@ -944,7 +1014,7 @@ class WeatherSatDecoder:
|
||||
if 'ndvi' in name:
|
||||
return 'NDVI Vegetation'
|
||||
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:
|
||||
return f'Channel {match.group(1)}'
|
||||
if 'avhrr' in name:
|
||||
@@ -967,13 +1037,16 @@ class WeatherSatDecoder:
|
||||
self._running = False
|
||||
self._stop_event.set()
|
||||
self._close_pty()
|
||||
|
||||
if self._process:
|
||||
safe_terminate(self._process)
|
||||
self._process = None
|
||||
|
||||
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
|
||||
|
||||
# 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."""
|
||||
@@ -1020,6 +1093,7 @@ class WeatherSatDecoder:
|
||||
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."""
|
||||
|
||||
@@ -4,12 +4,12 @@ Automatically captures satellite passes based on predicted pass times.
|
||||
Uses threading.Timer for scheduling — no external dependencies required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any, Callable
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any, Callable
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.weather_sat import get_weather_sat_decoder, WEATHER_SATELLITES, CaptureProgress
|
||||
@@ -21,13 +21,15 @@ try:
|
||||
from config import (
|
||||
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES,
|
||||
WEATHER_SAT_CAPTURE_BUFFER_SECONDS,
|
||||
WEATHER_SAT_SAMPLE_RATE,
|
||||
)
|
||||
except ImportError:
|
||||
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = 30
|
||||
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = 30
|
||||
WEATHER_SAT_SAMPLE_RATE = 2400000
|
||||
|
||||
|
||||
class ScheduledPass:
|
||||
class ScheduledPass:
|
||||
"""A pass scheduled for automatic capture."""
|
||||
|
||||
def __init__(self, pass_data: dict[str, Any]):
|
||||
@@ -46,13 +48,13 @@ class ScheduledPass:
|
||||
self._timer: threading.Timer | None = None
|
||||
self._stop_timer: threading.Timer | None = None
|
||||
|
||||
@property
|
||||
def start_dt(self) -> datetime:
|
||||
return _parse_utc_iso(self.start_time)
|
||||
|
||||
@property
|
||||
def end_dt(self) -> datetime:
|
||||
return _parse_utc_iso(self.end_time)
|
||||
@property
|
||||
def start_dt(self) -> datetime:
|
||||
return _parse_utc_iso(self.start_time)
|
||||
|
||||
@property
|
||||
def end_dt(self) -> datetime:
|
||||
return _parse_utc_iso(self.end_time)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -71,7 +73,7 @@ class ScheduledPass:
|
||||
}
|
||||
|
||||
|
||||
class WeatherSatScheduler:
|
||||
class WeatherSatScheduler:
|
||||
"""Auto-scheduler for weather satellite captures."""
|
||||
|
||||
def __init__(self):
|
||||
@@ -200,10 +202,10 @@ class WeatherSatScheduler:
|
||||
with self._lock:
|
||||
return [p.to_dict() for p in self._passes]
|
||||
|
||||
def _refresh_passes(self) -> None:
|
||||
"""Recompute passes and schedule timers."""
|
||||
if not self._enabled:
|
||||
return
|
||||
def _refresh_passes(self) -> None:
|
||||
"""Recompute passes and schedule timers."""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
from utils.weather_sat_predict import predict_passes
|
||||
@@ -227,39 +229,39 @@ class WeatherSatScheduler:
|
||||
p._stop_timer.cancel()
|
||||
|
||||
# Keep completed/skipped for history, replace scheduled
|
||||
history = [p for p in self._passes if p.status in ('complete', 'skipped', 'capturing')]
|
||||
self._passes = history
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
buffer = WEATHER_SAT_CAPTURE_BUFFER_SECONDS
|
||||
|
||||
for pass_data in passes:
|
||||
try:
|
||||
sp = ScheduledPass(pass_data)
|
||||
start_dt = sp.start_dt
|
||||
end_dt = sp.end_dt
|
||||
except Exception as e:
|
||||
logger.warning(f"Skipping invalid pass data: {e}")
|
||||
continue
|
||||
|
||||
capture_start = start_dt - timedelta(seconds=buffer)
|
||||
capture_end = end_dt + timedelta(seconds=buffer)
|
||||
|
||||
# Skip passes that are already over
|
||||
if capture_end <= now:
|
||||
continue
|
||||
|
||||
# Check if already in history
|
||||
if any(h.id == sp.id for h in history):
|
||||
continue
|
||||
|
||||
# Schedule capture timer. If we're already inside the capture
|
||||
# window, trigger immediately instead of skipping the pass.
|
||||
delay = max(0.0, (capture_start - now).total_seconds())
|
||||
sp._timer = threading.Timer(delay, self._execute_capture, args=[sp])
|
||||
sp._timer.daemon = True
|
||||
sp._timer.start()
|
||||
self._passes.append(sp)
|
||||
history = [p for p in self._passes if p.status in ('complete', 'skipped', 'capturing')]
|
||||
self._passes = history
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
buffer = WEATHER_SAT_CAPTURE_BUFFER_SECONDS
|
||||
|
||||
for pass_data in passes:
|
||||
try:
|
||||
sp = ScheduledPass(pass_data)
|
||||
start_dt = sp.start_dt
|
||||
end_dt = sp.end_dt
|
||||
except Exception as e:
|
||||
logger.warning(f"Skipping invalid pass data: {e}")
|
||||
continue
|
||||
|
||||
capture_start = start_dt - timedelta(seconds=buffer)
|
||||
capture_end = end_dt + timedelta(seconds=buffer)
|
||||
|
||||
# Skip passes that are already over
|
||||
if capture_end <= now:
|
||||
continue
|
||||
|
||||
# Check if already in history
|
||||
if any(h.id == sp.id for h in history):
|
||||
continue
|
||||
|
||||
# Schedule capture timer. If we're already inside the capture
|
||||
# window, trigger immediately instead of skipping the pass.
|
||||
delay = max(0.0, (capture_start - now).total_seconds())
|
||||
sp._timer = threading.Timer(delay, self._execute_capture, args=[sp])
|
||||
sp._timer.daemon = True
|
||||
sp._timer.start()
|
||||
self._passes.append(sp)
|
||||
|
||||
logger.info(
|
||||
f"Scheduler refreshed: {sum(1 for p in self._passes if p.status == 'scheduled')} "
|
||||
@@ -320,16 +322,31 @@ class WeatherSatScheduler:
|
||||
def _release_device():
|
||||
try:
|
||||
import app as app_module
|
||||
owner = None
|
||||
get_status = getattr(app_module, 'get_sdr_device_status', None)
|
||||
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))
|
||||
|
||||
success = decoder.start(
|
||||
success, _error_msg = decoder.start(
|
||||
satellite=sp.satellite,
|
||||
device_index=self._device,
|
||||
gain=self._gain,
|
||||
sample_rate=WEATHER_SAT_SAMPLE_RATE,
|
||||
bias_t=self._bias_t,
|
||||
)
|
||||
|
||||
@@ -374,31 +391,31 @@ class WeatherSatScheduler:
|
||||
|
||||
def _emit_event(self, event: dict[str, Any]) -> None:
|
||||
"""Emit scheduler event to callback."""
|
||||
if self._event_callback:
|
||||
try:
|
||||
self._event_callback(event)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in scheduler event callback: {e}")
|
||||
|
||||
|
||||
def _parse_utc_iso(value: str) -> datetime:
|
||||
"""Parse UTC ISO8601 timestamp robustly across Python versions."""
|
||||
if not value:
|
||||
raise ValueError("missing timestamp")
|
||||
|
||||
text = str(value).strip()
|
||||
# Backward compatibility for malformed legacy strings.
|
||||
text = text.replace('+00:00Z', 'Z')
|
||||
# Python <3.11 does not accept trailing 'Z' in fromisoformat.
|
||||
if text.endswith('Z'):
|
||||
text = text[:-1] + '+00:00'
|
||||
|
||||
dt = datetime.fromisoformat(text)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
dt = dt.astimezone(timezone.utc)
|
||||
return dt
|
||||
if self._event_callback:
|
||||
try:
|
||||
self._event_callback(event)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in scheduler event callback: {e}")
|
||||
|
||||
|
||||
def _parse_utc_iso(value: str) -> datetime:
|
||||
"""Parse UTC ISO8601 timestamp robustly across Python versions."""
|
||||
if not value:
|
||||
raise ValueError("missing timestamp")
|
||||
|
||||
text = str(value).strip()
|
||||
# Backward compatibility for malformed legacy strings.
|
||||
text = text.replace('+00:00Z', 'Z')
|
||||
# Python <3.11 does not accept trailing 'Z' in fromisoformat.
|
||||
if text.endswith('Z'):
|
||||
text = text[:-1] + '+00:00'
|
||||
|
||||
dt = datetime.fromisoformat(text)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
dt = dt.astimezone(timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
# Singleton
|
||||
|
||||
+844
@@ -0,0 +1,844 @@
|
||||
"""WeFax (Weather Fax) decoder.
|
||||
|
||||
Decodes HF radiofax (weather fax) transmissions using any supported SDR
|
||||
(RTL-SDR, HackRF, LimeSDR, Airspy, SDRPlay) via the SDRFactory
|
||||
abstraction layer. The decoder implements the standard WeFax AM protocol:
|
||||
carrier 1900 Hz, deviation +/-400 Hz (black=1500, white=2300).
|
||||
|
||||
Pipeline: rtl_fm/rx_fm -M usb -> stdout PCM -> Python DSP state machine
|
||||
|
||||
State machine: SCANNING -> PHASING -> RECEIVING -> COMPLETE
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import io
|
||||
import math
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import numpy as np
|
||||
|
||||
from utils.dependencies import get_tool_path
|
||||
from utils.logging import get_logger
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
|
||||
logger = get_logger('intercept.wefax')
|
||||
|
||||
try:
|
||||
from PIL import Image as PILImage
|
||||
except ImportError:
|
||||
PILImage = None # type: ignore[assignment,misc]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WeFax protocol constants
|
||||
# ---------------------------------------------------------------------------
|
||||
CARRIER_FREQ = 1900.0 # Hz - center/carrier
|
||||
BLACK_FREQ = 1500.0 # Hz - black level
|
||||
WHITE_FREQ = 2300.0 # Hz - white level
|
||||
START_TONE_FREQ = 300.0 # Hz - start tone
|
||||
STOP_TONE_FREQ = 450.0 # Hz - stop tone
|
||||
PHASING_FREQ = WHITE_FREQ # White pulse during phasing
|
||||
|
||||
START_TONE_DURATION = 3.0 # Minimum seconds of start tone to detect
|
||||
STOP_TONE_DURATION = 3.0 # Minimum seconds of stop tone to detect
|
||||
PHASING_MIN_LINES = 5 # Minimum phasing lines before image
|
||||
|
||||
DEFAULT_SAMPLE_RATE = 22050
|
||||
DEFAULT_IOC = 576
|
||||
DEFAULT_LPM = 120
|
||||
|
||||
|
||||
class DecoderState(Enum):
|
||||
"""WeFax decoder state machine states."""
|
||||
SCANNING = 'scanning'
|
||||
START_DETECTED = 'start_detected'
|
||||
PHASING = 'phasing'
|
||||
RECEIVING = 'receiving'
|
||||
COMPLETE = 'complete'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataclasses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class WeFaxImage:
|
||||
"""Decoded WeFax image metadata."""
|
||||
filename: str
|
||||
path: Path
|
||||
station: str
|
||||
frequency_khz: float
|
||||
timestamp: datetime
|
||||
ioc: int
|
||||
lpm: int
|
||||
size_bytes: int = 0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'filename': self.filename,
|
||||
'path': str(self.path),
|
||||
'station': self.station,
|
||||
'frequency_khz': self.frequency_khz,
|
||||
'timestamp': self.timestamp.isoformat(),
|
||||
'ioc': self.ioc,
|
||||
'lpm': self.lpm,
|
||||
'size_bytes': self.size_bytes,
|
||||
'url': f'/wefax/images/{self.filename}',
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeFaxProgress:
|
||||
"""WeFax decode progress update for SSE streaming."""
|
||||
status: str # 'scanning', 'phasing', 'receiving', 'complete', 'error', 'stopped'
|
||||
station: str = ''
|
||||
message: str = ''
|
||||
progress_percent: int = 0
|
||||
line_count: int = 0
|
||||
image: WeFaxImage | None = None
|
||||
partial_image: str | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
result: dict = {
|
||||
'type': 'wefax_progress',
|
||||
'status': self.status,
|
||||
'progress': self.progress_percent,
|
||||
}
|
||||
if self.station:
|
||||
result['station'] = self.station
|
||||
if self.message:
|
||||
result['message'] = self.message
|
||||
if self.line_count:
|
||||
result['line_count'] = self.line_count
|
||||
if self.image:
|
||||
result['image'] = self.image.to_dict()
|
||||
if self.partial_image:
|
||||
result['partial_image'] = self.partial_image
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DSP helpers (reuse Goertzel from SSTV where sensible)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _goertzel_mag(samples: np.ndarray, target_freq: float,
|
||||
sample_rate: int) -> float:
|
||||
"""Compute Goertzel magnitude at a single frequency."""
|
||||
n = len(samples)
|
||||
if n == 0:
|
||||
return 0.0
|
||||
w = 2.0 * math.pi * target_freq / sample_rate
|
||||
coeff = 2.0 * math.cos(w)
|
||||
s1 = 0.0
|
||||
s2 = 0.0
|
||||
for sample in samples:
|
||||
s0 = float(sample) + coeff * s1 - s2
|
||||
s2 = s1
|
||||
s1 = s0
|
||||
energy = s1 * s1 + s2 * s2 - coeff * s1 * s2
|
||||
return math.sqrt(max(0.0, energy))
|
||||
|
||||
|
||||
def _freq_to_pixel(frequency: float) -> int:
|
||||
"""Map WeFax audio frequency to pixel value (0=black, 255=white).
|
||||
|
||||
Linear mapping: 1500 Hz -> 0 (black), 2300 Hz -> 255 (white).
|
||||
"""
|
||||
normalized = (frequency - BLACK_FREQ) / (WHITE_FREQ - BLACK_FREQ)
|
||||
return max(0, min(255, int(normalized * 255 + 0.5)))
|
||||
|
||||
|
||||
def _estimate_frequency(samples: np.ndarray, sample_rate: int,
|
||||
freq_low: float = 1200.0,
|
||||
freq_high: float = 2500.0) -> float:
|
||||
"""Estimate dominant frequency using coarse+fine Goertzel sweep."""
|
||||
if len(samples) == 0:
|
||||
return 0.0
|
||||
|
||||
best_freq = freq_low
|
||||
best_energy = 0.0
|
||||
|
||||
# Coarse sweep (25 Hz steps)
|
||||
freq = freq_low
|
||||
while freq <= freq_high:
|
||||
energy = _goertzel_mag(samples, freq, sample_rate) ** 2
|
||||
if energy > best_energy:
|
||||
best_energy = energy
|
||||
best_freq = freq
|
||||
freq += 25.0
|
||||
|
||||
# Fine sweep around peak (+/- 25 Hz, 5 Hz steps)
|
||||
fine_low = max(freq_low, best_freq - 25.0)
|
||||
fine_high = min(freq_high, best_freq + 25.0)
|
||||
freq = fine_low
|
||||
while freq <= fine_high:
|
||||
energy = _goertzel_mag(samples, freq, sample_rate) ** 2
|
||||
if energy > best_energy:
|
||||
best_energy = energy
|
||||
best_freq = freq
|
||||
freq += 5.0
|
||||
|
||||
return best_freq
|
||||
|
||||
|
||||
def _detect_tone(samples: np.ndarray, target_freq: float,
|
||||
sample_rate: int, threshold: float = 3.0) -> bool:
|
||||
"""Detect if a specific tone dominates the signal."""
|
||||
target_mag = _goertzel_mag(samples, target_freq, sample_rate)
|
||||
# Check against a few reference frequencies
|
||||
refs = [1000.0, 1500.0, 1900.0, 2300.0]
|
||||
refs = [f for f in refs if abs(f - target_freq) > 100]
|
||||
if not refs:
|
||||
return target_mag > 0.01
|
||||
avg_ref = sum(_goertzel_mag(samples, f, sample_rate) for f in refs) / len(refs)
|
||||
if avg_ref <= 0:
|
||||
return target_mag > 0.01
|
||||
return target_mag / avg_ref >= threshold
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WeFaxDecoder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class WeFaxDecoder:
|
||||
"""WeFax decoder singleton.
|
||||
|
||||
Manages SDR FM demod subprocess and decodes WeFax images using a
|
||||
state machine that detects start/stop tones, phasing signals, and
|
||||
demodulates image lines.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._sdr_process: subprocess.Popen | None = None
|
||||
self._running = False
|
||||
self._lock = threading.Lock()
|
||||
self._callback: Callable[[dict], None] | None = None
|
||||
self._last_scope_time: float = 0.0
|
||||
self._output_dir = Path('instance/wefax_images')
|
||||
self._images: list[WeFaxImage] = []
|
||||
self._decode_thread: threading.Thread | None = None
|
||||
|
||||
# Current session parameters
|
||||
self._station = ''
|
||||
self._frequency_khz = 0.0
|
||||
self._ioc = DEFAULT_IOC
|
||||
self._lpm = DEFAULT_LPM
|
||||
self._sample_rate = DEFAULT_SAMPLE_RATE
|
||||
self._device_index = 0
|
||||
self._gain = 40.0
|
||||
self._direct_sampling = True
|
||||
|
||||
self._output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._sdr_tool_name: str = 'rtl_fm'
|
||||
self._last_error: str = ''
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def last_error(self) -> str:
|
||||
"""Last error message from a failed start() attempt."""
|
||||
return self._last_error
|
||||
|
||||
def set_callback(self, callback: Callable[[dict], None]) -> None:
|
||||
"""Set callback for progress updates (fed to SSE queue)."""
|
||||
self._callback = callback
|
||||
|
||||
def start(
|
||||
self,
|
||||
frequency_khz: float,
|
||||
station: str = '',
|
||||
device_index: int = 0,
|
||||
gain: float = 40.0,
|
||||
ioc: int = DEFAULT_IOC,
|
||||
lpm: int = DEFAULT_LPM,
|
||||
direct_sampling: bool = True,
|
||||
sdr_type: str = 'rtlsdr',
|
||||
) -> bool:
|
||||
"""Start WeFax decoder.
|
||||
|
||||
Args:
|
||||
frequency_khz: Frequency in kHz (e.g. 4298 for NOJ).
|
||||
station: Station callsign for metadata.
|
||||
device_index: SDR device index.
|
||||
gain: Receiver gain in dB.
|
||||
ioc: Index of Cooperation (576 or 288).
|
||||
lpm: Lines per minute (120 or 60).
|
||||
direct_sampling: Enable RTL-SDR direct sampling for HF.
|
||||
sdr_type: SDR hardware type (rtlsdr, hackrf, limesdr, airspy, sdrplay).
|
||||
|
||||
Returns:
|
||||
True if started successfully.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._running:
|
||||
return True
|
||||
|
||||
self._station = station
|
||||
self._frequency_khz = frequency_khz
|
||||
self._ioc = ioc
|
||||
self._lpm = lpm
|
||||
self._device_index = device_index
|
||||
self._gain = gain
|
||||
self._direct_sampling = direct_sampling
|
||||
self._sdr_type = sdr_type
|
||||
self._sample_rate = DEFAULT_SAMPLE_RATE
|
||||
|
||||
try:
|
||||
self._running = True
|
||||
self._last_error = ''
|
||||
self._start_pipeline_spawn()
|
||||
except Exception as e:
|
||||
self._running = False
|
||||
self._last_error = str(e)
|
||||
logger.error(f"Failed to start WeFax decoder: {e}")
|
||||
self._emit_progress(WeFaxProgress(
|
||||
status='error',
|
||||
message=str(e),
|
||||
))
|
||||
return False
|
||||
|
||||
# Health check sleep outside lock
|
||||
try:
|
||||
self._start_pipeline_health_check()
|
||||
logger.info(
|
||||
f"WeFax decoder started: {frequency_khz} kHz, "
|
||||
f"station={station}, IOC={ioc}, LPM={lpm}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
self._running = False
|
||||
self._last_error = str(e)
|
||||
logger.error(f"Failed to start WeFax decoder: {e}")
|
||||
self._emit_progress(WeFaxProgress(
|
||||
status='error',
|
||||
message=str(e),
|
||||
))
|
||||
return False
|
||||
|
||||
def _start_pipeline(self) -> None:
|
||||
"""Start SDR FM demod subprocess in USB mode for WeFax."""
|
||||
self._start_pipeline_spawn()
|
||||
self._start_pipeline_health_check()
|
||||
|
||||
def _start_pipeline_spawn(self) -> None:
|
||||
"""Spawn the SDR FM demod subprocess. Must hold self._lock."""
|
||||
try:
|
||||
sdr_type_enum = SDRType(self._sdr_type)
|
||||
except ValueError:
|
||||
sdr_type_enum = SDRType.RTL_SDR
|
||||
|
||||
# Validate that the required tool is available
|
||||
if sdr_type_enum == SDRType.RTL_SDR:
|
||||
if not get_tool_path('rtl_fm'):
|
||||
raise RuntimeError('rtl_fm not found')
|
||||
else:
|
||||
if not get_tool_path('rx_fm'):
|
||||
raise RuntimeError('rx_fm not found (required for non-RTL-SDR devices)')
|
||||
|
||||
sdr_device = SDRFactory.create_default_device(
|
||||
sdr_type_enum, index=self._device_index)
|
||||
builder = SDRFactory.get_builder(sdr_type_enum)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=self._frequency_khz / 1000.0,
|
||||
sample_rate=self._sample_rate,
|
||||
gain=self._gain,
|
||||
modulation='usb',
|
||||
)
|
||||
|
||||
# RTL-SDR: append direct sampling flag for HF reception
|
||||
if sdr_type_enum == SDRType.RTL_SDR and self._direct_sampling:
|
||||
# Insert before trailing '-' stdout marker
|
||||
if rtl_cmd and rtl_cmd[-1] == '-':
|
||||
rtl_cmd.insert(-1, '-E')
|
||||
rtl_cmd.insert(-1, 'direct2')
|
||||
else:
|
||||
rtl_cmd.extend(['-E', 'direct2', '-'])
|
||||
|
||||
self._sdr_tool_name = rtl_cmd[0] if rtl_cmd else 'sdr'
|
||||
logger.info(f"Starting {self._sdr_tool_name}: {' '.join(rtl_cmd)}")
|
||||
|
||||
self._sdr_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
def _start_pipeline_health_check(self) -> None:
|
||||
"""Post-spawn health check and decode thread start. Called outside lock."""
|
||||
time.sleep(0.3)
|
||||
|
||||
with self._lock:
|
||||
if self._sdr_process and self._sdr_process.poll() is not None:
|
||||
stderr_detail = ''
|
||||
if self._sdr_process.stderr:
|
||||
stderr_detail = self._sdr_process.stderr.read().decode(
|
||||
errors='replace').strip()
|
||||
rc = self._sdr_process.returncode
|
||||
self._sdr_process = None
|
||||
detail = stderr_detail.split('\n')[-1] if stderr_detail else f'exit code {rc}'
|
||||
raise RuntimeError(f'{self._sdr_tool_name} failed: {detail}')
|
||||
|
||||
self._decode_thread = threading.Thread(
|
||||
target=self._decode_audio_stream, daemon=True)
|
||||
self._decode_thread.start()
|
||||
|
||||
def _decode_audio_stream(self) -> None:
|
||||
"""Read audio from SDR FM demod and decode WeFax images.
|
||||
|
||||
Runs in a background thread. Processes 100ms chunks through
|
||||
the start-tone / phasing / image state machine.
|
||||
"""
|
||||
sr = self._sample_rate
|
||||
chunk_samples = sr // 10 # 100ms
|
||||
chunk_bytes = chunk_samples * 2 # int16
|
||||
|
||||
state = DecoderState.SCANNING
|
||||
start_tone_count = 0
|
||||
stop_tone_count = 0
|
||||
phasing_line_count = 0
|
||||
|
||||
# Image parameters
|
||||
pixels_per_line = int(math.pi * self._ioc)
|
||||
line_duration_s = 60.0 / self._lpm
|
||||
samples_per_line = int(line_duration_s * sr)
|
||||
|
||||
# Image buffer
|
||||
image_lines: list[np.ndarray] = []
|
||||
line_buffer = np.zeros(0, dtype=np.float64)
|
||||
max_lines = 2000 # Safety limit
|
||||
|
||||
sdr_error = ''
|
||||
last_partial_line = -1
|
||||
|
||||
logger.info(
|
||||
f"WeFax decode thread started: IOC={self._ioc}, "
|
||||
f"LPM={self._lpm}, pixels/line={pixels_per_line}, "
|
||||
f"samples/line={samples_per_line}"
|
||||
)
|
||||
|
||||
# Emit initial scanning progress here (not in start()) so the
|
||||
# frontend SSE connection is established before this event fires.
|
||||
time.sleep(0.1)
|
||||
self._emit_progress(WeFaxProgress(
|
||||
status='scanning',
|
||||
station=self._station,
|
||||
message=f'Scanning {self._frequency_khz} kHz for WeFax start tone...',
|
||||
))
|
||||
|
||||
while self._running and self._sdr_process:
|
||||
try:
|
||||
proc = self._sdr_process
|
||||
if not proc or not proc.stdout:
|
||||
break
|
||||
# Non-blocking read via select() — allows checking _running
|
||||
# on timeout instead of blocking indefinitely in read().
|
||||
fd = proc.stdout.fileno()
|
||||
ready, _, _ = select.select([fd], [], [], 0.5)
|
||||
if not ready:
|
||||
if not self._running:
|
||||
break
|
||||
continue
|
||||
raw_data = os.read(fd, chunk_bytes)
|
||||
if not raw_data:
|
||||
if self._running:
|
||||
stderr_msg = ''
|
||||
if self._sdr_process and self._sdr_process.stderr:
|
||||
with contextlib.suppress(Exception):
|
||||
stderr_msg = self._sdr_process.stderr.read().decode(
|
||||
errors='replace').strip()
|
||||
rc = self._sdr_process.poll() if self._sdr_process else None
|
||||
logger.warning(f"{self._sdr_tool_name} stream ended (exit code: {rc})")
|
||||
if stderr_msg:
|
||||
logger.warning(f"{self._sdr_tool_name} stderr: {stderr_msg}")
|
||||
sdr_error = stderr_msg
|
||||
break
|
||||
|
||||
n_samples = len(raw_data) // 2
|
||||
if n_samples == 0:
|
||||
continue
|
||||
|
||||
raw_int16 = np.frombuffer(raw_data[:n_samples * 2], dtype=np.int16)
|
||||
samples = raw_int16.astype(np.float64) / 32768.0
|
||||
|
||||
# Emit scope waveform for frontend visualisation
|
||||
self._emit_scope(raw_int16)
|
||||
|
||||
if state == DecoderState.SCANNING:
|
||||
# Look for 300 Hz start tone
|
||||
if _detect_tone(samples, START_TONE_FREQ, sr, threshold=2.5):
|
||||
start_tone_count += 1
|
||||
# Need sustained detection (>= START_TONE_DURATION seconds)
|
||||
needed = int(START_TONE_DURATION / 0.1)
|
||||
if start_tone_count >= needed:
|
||||
state = DecoderState.PHASING
|
||||
phasing_line_count = 0
|
||||
logger.info("WeFax start tone detected, entering phasing")
|
||||
self._emit_progress(WeFaxProgress(
|
||||
status='phasing',
|
||||
station=self._station,
|
||||
message='Start tone detected, synchronising...',
|
||||
))
|
||||
else:
|
||||
start_tone_count = max(0, start_tone_count - 1)
|
||||
|
||||
elif state == DecoderState.PHASING:
|
||||
# Count phasing lines (alternating black/white pulses)
|
||||
phasing_line_count += 1
|
||||
needed_phasing = max(PHASING_MIN_LINES, int(2.0 / 0.1))
|
||||
if phasing_line_count >= needed_phasing:
|
||||
state = DecoderState.RECEIVING
|
||||
image_lines = []
|
||||
line_buffer = np.zeros(0, dtype=np.float64)
|
||||
last_partial_line = -1
|
||||
logger.info("Phasing complete, receiving image")
|
||||
self._emit_progress(WeFaxProgress(
|
||||
status='receiving',
|
||||
station=self._station,
|
||||
message='Receiving image...',
|
||||
))
|
||||
|
||||
elif state == DecoderState.RECEIVING:
|
||||
# Check for stop tone
|
||||
if _detect_tone(samples, STOP_TONE_FREQ, sr, threshold=2.5):
|
||||
stop_tone_count += 1
|
||||
needed_stop = int(STOP_TONE_DURATION / 0.1)
|
||||
if stop_tone_count >= needed_stop:
|
||||
# Process any remaining line buffer
|
||||
if len(line_buffer) >= samples_per_line * 0.5:
|
||||
line_pixels = self._decode_line(
|
||||
line_buffer, pixels_per_line, sr)
|
||||
image_lines.append(line_pixels)
|
||||
|
||||
state = DecoderState.COMPLETE
|
||||
logger.info(
|
||||
f"Stop tone detected, image complete: "
|
||||
f"{len(image_lines)} lines"
|
||||
)
|
||||
break
|
||||
else:
|
||||
stop_tone_count = max(0, stop_tone_count - 1)
|
||||
|
||||
# Accumulate samples into line buffer
|
||||
line_buffer = np.concatenate([line_buffer, samples])
|
||||
|
||||
# Extract complete lines
|
||||
while len(line_buffer) >= samples_per_line:
|
||||
line_samples = line_buffer[:samples_per_line]
|
||||
line_buffer = line_buffer[samples_per_line:]
|
||||
|
||||
line_pixels = self._decode_line(
|
||||
line_samples, pixels_per_line, sr)
|
||||
image_lines.append(line_pixels)
|
||||
|
||||
# Safety limit
|
||||
if len(image_lines) >= max_lines:
|
||||
logger.warning("WeFax max lines reached, saving image")
|
||||
state = DecoderState.COMPLETE
|
||||
break
|
||||
|
||||
# Emit progress periodically
|
||||
current_lines = len(image_lines)
|
||||
if current_lines > 0 and current_lines != last_partial_line and current_lines % 20 == 0:
|
||||
last_partial_line = current_lines
|
||||
# Rough progress estimate (typical chart ~800 lines)
|
||||
pct = min(95, int(current_lines / 8))
|
||||
partial_url = self._encode_partial(
|
||||
image_lines, pixels_per_line)
|
||||
self._emit_progress(WeFaxProgress(
|
||||
status='receiving',
|
||||
station=self._station,
|
||||
message=f'Receiving: {current_lines} lines',
|
||||
progress_percent=pct,
|
||||
line_count=current_lines,
|
||||
partial_image=partial_url,
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in WeFax decode thread: {e}")
|
||||
if not self._running:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
# Save image if we got data
|
||||
if state == DecoderState.COMPLETE and image_lines:
|
||||
self._save_image(image_lines, pixels_per_line)
|
||||
elif state == DecoderState.RECEIVING and len(image_lines) > 20:
|
||||
# Save partial image if we had significant data
|
||||
logger.info(f"Saving partial WeFax image: {len(image_lines)} lines")
|
||||
self._save_image(image_lines, pixels_per_line)
|
||||
|
||||
# Clean up
|
||||
with self._lock:
|
||||
was_running = self._running
|
||||
self._running = False
|
||||
if self._sdr_process:
|
||||
with contextlib.suppress(Exception):
|
||||
self._sdr_process.terminate()
|
||||
self._sdr_process.wait(timeout=2)
|
||||
self._sdr_process = None
|
||||
|
||||
if was_running:
|
||||
err_detail = sdr_error.split('\n')[-1] if sdr_error else ''
|
||||
if state != DecoderState.COMPLETE:
|
||||
msg = f'{self._sdr_tool_name} failed: {err_detail}' if err_detail else 'Decode stopped unexpectedly'
|
||||
self._emit_progress(WeFaxProgress(
|
||||
status='error', message=msg))
|
||||
else:
|
||||
self._emit_progress(WeFaxProgress(
|
||||
status='stopped', message='Decoder stopped'))
|
||||
|
||||
logger.info("WeFax decode thread ended")
|
||||
|
||||
def _decode_line(self, line_samples: np.ndarray,
|
||||
pixels_per_line: int, sample_rate: int) -> np.ndarray:
|
||||
"""Decode one scan line from audio samples to pixel values.
|
||||
|
||||
Uses instantaneous frequency estimation via the analytic signal
|
||||
(Hilbert transform), then maps frequency to grayscale.
|
||||
"""
|
||||
n = len(line_samples)
|
||||
pixels = np.zeros(pixels_per_line, dtype=np.uint8)
|
||||
|
||||
if n < pixels_per_line:
|
||||
return pixels
|
||||
|
||||
samples_per_pixel = n / pixels_per_line
|
||||
|
||||
# Use Hilbert transform for instantaneous frequency
|
||||
try:
|
||||
analytic = np.fft.ifft(
|
||||
np.fft.fft(line_samples) * 2 * (np.arange(n) < n // 2))
|
||||
inst_phase = np.unwrap(np.angle(analytic))
|
||||
inst_freq = np.diff(inst_phase) / (2.0 * math.pi) * sample_rate
|
||||
inst_freq = np.clip(inst_freq, BLACK_FREQ - 200, WHITE_FREQ + 200)
|
||||
|
||||
# Average frequency per pixel
|
||||
for px in range(pixels_per_line):
|
||||
start_idx = int(px * samples_per_pixel)
|
||||
end_idx = int((px + 1) * samples_per_pixel)
|
||||
end_idx = min(end_idx, len(inst_freq))
|
||||
if start_idx >= end_idx:
|
||||
continue
|
||||
avg_freq = float(np.mean(inst_freq[start_idx:end_idx]))
|
||||
pixels[px] = _freq_to_pixel(avg_freq)
|
||||
|
||||
except Exception:
|
||||
# Fallback: simple Goertzel per pixel window
|
||||
for px in range(pixels_per_line):
|
||||
start_idx = int(px * samples_per_pixel)
|
||||
end_idx = int((px + 1) * samples_per_pixel)
|
||||
if start_idx >= len(line_samples) or start_idx >= end_idx:
|
||||
break
|
||||
window = line_samples[start_idx:end_idx]
|
||||
freq = _estimate_frequency(window, sample_rate,
|
||||
BLACK_FREQ - 200, WHITE_FREQ + 200)
|
||||
pixels[px] = _freq_to_pixel(freq)
|
||||
|
||||
return pixels
|
||||
|
||||
def _encode_partial(self, image_lines: list[np.ndarray],
|
||||
width: int) -> str | None:
|
||||
"""Encode current image lines as a JPEG data URL for live preview."""
|
||||
if PILImage is None or not image_lines:
|
||||
return None
|
||||
try:
|
||||
height = len(image_lines)
|
||||
img_array = np.zeros((height, width), dtype=np.uint8)
|
||||
for i, line in enumerate(image_lines):
|
||||
img_array[i, :len(line)] = line[:width]
|
||||
img = PILImage.fromarray(img_array, mode='L')
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='JPEG', quality=40)
|
||||
b64 = base64.b64encode(buf.getvalue()).decode('ascii')
|
||||
return f'data:image/jpeg;base64,{b64}'
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _save_image(self, image_lines: list[np.ndarray],
|
||||
width: int) -> None:
|
||||
"""Save completed image to disk."""
|
||||
if PILImage is None:
|
||||
logger.error("Cannot save image: Pillow not installed")
|
||||
self._emit_progress(WeFaxProgress(
|
||||
status='error',
|
||||
message='Cannot save image - Pillow not installed',
|
||||
))
|
||||
return
|
||||
|
||||
try:
|
||||
height = len(image_lines)
|
||||
img_array = np.zeros((height, width), dtype=np.uint8)
|
||||
for i, line in enumerate(image_lines):
|
||||
img_array[i, :len(line)] = line[:width]
|
||||
|
||||
img = PILImage.fromarray(img_array, mode='L')
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
station_tag = self._station or 'unknown'
|
||||
filename = f"wefax_{timestamp.strftime('%Y%m%d_%H%M%S')}_{station_tag}.png"
|
||||
filepath = self._output_dir / filename
|
||||
img.save(filepath, 'PNG')
|
||||
|
||||
wefax_image = WeFaxImage(
|
||||
filename=filename,
|
||||
path=filepath,
|
||||
station=self._station,
|
||||
frequency_khz=self._frequency_khz,
|
||||
timestamp=timestamp,
|
||||
ioc=self._ioc,
|
||||
lpm=self._lpm,
|
||||
size_bytes=filepath.stat().st_size,
|
||||
)
|
||||
self._images.append(wefax_image)
|
||||
|
||||
logger.info(f"WeFax image saved: {filename} ({wefax_image.size_bytes} bytes)")
|
||||
self._emit_progress(WeFaxProgress(
|
||||
status='complete',
|
||||
station=self._station,
|
||||
message=f'Image decoded: {height} lines',
|
||||
progress_percent=100,
|
||||
line_count=height,
|
||||
image=wefax_image,
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving WeFax image: {e}")
|
||||
self._emit_progress(WeFaxProgress(
|
||||
status='error',
|
||||
message=f'Error saving image: {e}',
|
||||
))
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop WeFax decoder.
|
||||
|
||||
Sets _running=False and terminates the process outside the lock,
|
||||
then waits briefly for the decode thread to finish saving any
|
||||
partial image before returning.
|
||||
"""
|
||||
with self._lock:
|
||||
self._running = False
|
||||
proc = self._sdr_process
|
||||
self._sdr_process = None
|
||||
thread = self._decode_thread
|
||||
|
||||
if proc:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.terminate()
|
||||
|
||||
# Wait for the decode thread to save any partial image.
|
||||
# With select()-based reads the thread exits within ~0.5s.
|
||||
if thread:
|
||||
with contextlib.suppress(Exception):
|
||||
thread.join(timeout=2)
|
||||
|
||||
logger.info("WeFax decoder stopped")
|
||||
|
||||
def get_images(self) -> list[WeFaxImage]:
|
||||
"""Get list of decoded images."""
|
||||
self._scan_images()
|
||||
return list(self._images)
|
||||
|
||||
def delete_image(self, filename: str) -> bool:
|
||||
"""Delete a single decoded image."""
|
||||
filepath = self._output_dir / filename
|
||||
if not filepath.exists():
|
||||
return False
|
||||
filepath.unlink()
|
||||
self._images = [img for img in self._images if img.filename != filename]
|
||||
logger.info(f"Deleted WeFax image: {filename}")
|
||||
return True
|
||||
|
||||
def delete_all_images(self) -> int:
|
||||
"""Delete all decoded images. Returns count deleted."""
|
||||
count = 0
|
||||
for filepath in self._output_dir.glob('*.png'):
|
||||
filepath.unlink()
|
||||
count += 1
|
||||
self._images.clear()
|
||||
logger.info(f"Deleted all WeFax images ({count} files)")
|
||||
return count
|
||||
|
||||
def _scan_images(self) -> None:
|
||||
"""Scan output directory for images not yet tracked."""
|
||||
known = {img.filename for img in self._images}
|
||||
for filepath in self._output_dir.glob('*.png'):
|
||||
if filepath.name not in known:
|
||||
try:
|
||||
stat = filepath.stat()
|
||||
image = WeFaxImage(
|
||||
filename=filepath.name,
|
||||
path=filepath,
|
||||
station='',
|
||||
frequency_khz=0,
|
||||
timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||
ioc=self._ioc,
|
||||
lpm=self._lpm,
|
||||
size_bytes=stat.st_size,
|
||||
)
|
||||
self._images.append(image)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error scanning image {filepath}: {e}")
|
||||
|
||||
def _emit_progress(self, progress: WeFaxProgress) -> None:
|
||||
"""Emit progress update to callback."""
|
||||
if self._callback:
|
||||
try:
|
||||
self._callback(progress.to_dict())
|
||||
except Exception as e:
|
||||
logger.error(f"Error in progress callback: {e}")
|
||||
|
||||
def _emit_scope(self, raw_int16: np.ndarray) -> None:
|
||||
"""Emit scope waveform data for frontend visualisation."""
|
||||
if not self._callback:
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
if now - self._last_scope_time < 0.1:
|
||||
return
|
||||
self._last_scope_time = now
|
||||
|
||||
try:
|
||||
peak = int(np.max(np.abs(raw_int16)))
|
||||
rms = int(np.sqrt(np.mean(raw_int16.astype(np.float64) ** 2)))
|
||||
|
||||
# Downsample to 256 signed int8 values for lightweight transport
|
||||
window = raw_int16[-256:] if len(raw_int16) > 256 else raw_int16
|
||||
waveform = np.clip(window // 256, -127, 127).astype(np.int8).tolist()
|
||||
|
||||
self._callback({
|
||||
'type': 'scope',
|
||||
'rms': rms,
|
||||
'peak': peak,
|
||||
'waveform': waveform,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_decoder: WeFaxDecoder | None = None
|
||||
|
||||
|
||||
def get_wefax_decoder() -> WeFaxDecoder:
|
||||
"""Get or create the global WeFax decoder instance."""
|
||||
global _decoder
|
||||
if _decoder is None:
|
||||
_decoder = WeFaxDecoder()
|
||||
return _decoder
|
||||
@@ -0,0 +1,543 @@
|
||||
"""WeFax auto-capture scheduler.
|
||||
|
||||
Automatically captures WeFax broadcasts based on station broadcast schedules.
|
||||
Uses threading.Timer for scheduling — no external dependencies required.
|
||||
|
||||
Unlike the weather satellite scheduler which uses TLE-based orbital prediction,
|
||||
WeFax stations broadcast on fixed UTC schedules, making scheduling simpler.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.wefax import get_wefax_decoder
|
||||
from utils.wefax_stations import get_station
|
||||
|
||||
logger = get_logger('intercept.wefax_scheduler')
|
||||
|
||||
# Import config defaults
|
||||
try:
|
||||
from config import (
|
||||
WEFAX_CAPTURE_BUFFER_SECONDS,
|
||||
WEFAX_SCHEDULE_REFRESH_MINUTES,
|
||||
)
|
||||
except ImportError:
|
||||
WEFAX_SCHEDULE_REFRESH_MINUTES = 30
|
||||
WEFAX_CAPTURE_BUFFER_SECONDS = 30
|
||||
|
||||
|
||||
class ScheduledBroadcast:
|
||||
"""A broadcast scheduled for automatic capture."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
station: str,
|
||||
callsign: str,
|
||||
frequency_khz: float,
|
||||
utc_time: str,
|
||||
duration_min: int,
|
||||
content: str,
|
||||
occurrence_date: str = '',
|
||||
):
|
||||
self.id: str = str(uuid.uuid4())[:8]
|
||||
self.station = station
|
||||
self.callsign = callsign
|
||||
self.frequency_khz = frequency_khz
|
||||
self.utc_time = utc_time
|
||||
self.duration_min = duration_min
|
||||
self.content = content
|
||||
self.occurrence_date = occurrence_date
|
||||
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]:
|
||||
return {
|
||||
'id': self.id,
|
||||
'station': self.station,
|
||||
'callsign': self.callsign,
|
||||
'frequency_khz': self.frequency_khz,
|
||||
'utc_time': self.utc_time,
|
||||
'duration_min': self.duration_min,
|
||||
'content': self.content,
|
||||
'occurrence_date': self.occurrence_date,
|
||||
'status': self.status,
|
||||
}
|
||||
|
||||
|
||||
class WeFaxScheduler:
|
||||
"""Auto-scheduler for WeFax broadcast captures."""
|
||||
|
||||
def __init__(self):
|
||||
self._enabled = False
|
||||
self._lock = threading.Lock()
|
||||
self._broadcasts: list[ScheduledBroadcast] = []
|
||||
self._refresh_timer: threading.Timer | None = None
|
||||
self._station: str = ''
|
||||
self._callsign: str = ''
|
||||
self._frequency_khz: float = 0.0
|
||||
self._device: int = 0
|
||||
self._gain: float = 40.0
|
||||
self._ioc: int = 576
|
||||
self._lpm: int = 120
|
||||
self._direct_sampling: bool = True
|
||||
self._progress_callback: Callable[[dict], None] | None = None
|
||||
self._event_callback: Callable[[dict[str, Any]], None] | None = None
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def set_callbacks(
|
||||
self,
|
||||
progress_callback: Callable[[dict], None],
|
||||
event_callback: Callable[[dict[str, Any]], None],
|
||||
) -> None:
|
||||
"""Set callbacks for progress and scheduler events."""
|
||||
self._progress_callback = progress_callback
|
||||
self._event_callback = event_callback
|
||||
|
||||
def enable(
|
||||
self,
|
||||
station: str,
|
||||
frequency_khz: float,
|
||||
device: int = 0,
|
||||
gain: float = 40.0,
|
||||
ioc: int = 576,
|
||||
lpm: int = 120,
|
||||
direct_sampling: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Enable auto-scheduling for a station/frequency.
|
||||
|
||||
Args:
|
||||
station: Station callsign.
|
||||
frequency_khz: Frequency in kHz.
|
||||
device: RTL-SDR device index.
|
||||
gain: SDR gain in dB.
|
||||
ioc: Index of Cooperation (576 or 288).
|
||||
lpm: Lines per minute (120 or 60).
|
||||
direct_sampling: Enable direct sampling for HF.
|
||||
|
||||
Returns:
|
||||
Status dict with scheduled broadcasts.
|
||||
"""
|
||||
station_data = get_station(station)
|
||||
if not station_data:
|
||||
return {'status': 'error', 'message': f'Station {station} not found'}
|
||||
|
||||
with self._lock:
|
||||
self._station = station_data.get('name', station)
|
||||
self._callsign = station
|
||||
self._frequency_khz = frequency_khz
|
||||
self._device = device
|
||||
self._gain = gain
|
||||
self._ioc = ioc
|
||||
self._lpm = lpm
|
||||
self._direct_sampling = direct_sampling
|
||||
self._enabled = True
|
||||
|
||||
self._refresh_schedule()
|
||||
|
||||
return self.get_status()
|
||||
|
||||
def disable(self) -> dict[str, Any]:
|
||||
"""Disable auto-scheduling and cancel all timers."""
|
||||
with self._lock:
|
||||
self._enabled = False
|
||||
|
||||
# Cancel refresh timer
|
||||
if self._refresh_timer:
|
||||
self._refresh_timer.cancel()
|
||||
self._refresh_timer = None
|
||||
|
||||
# Cancel all broadcast timers
|
||||
for b in self._broadcasts:
|
||||
if b._timer:
|
||||
b._timer.cancel()
|
||||
b._timer = None
|
||||
if b._stop_timer:
|
||||
b._stop_timer.cancel()
|
||||
b._stop_timer = None
|
||||
|
||||
self._broadcasts.clear()
|
||||
|
||||
logger.info("WeFax auto-scheduler disabled")
|
||||
return {'status': 'disabled'}
|
||||
|
||||
def skip_broadcast(self, broadcast_id: str) -> bool:
|
||||
"""Manually skip a scheduled broadcast."""
|
||||
with self._lock:
|
||||
for b in self._broadcasts:
|
||||
if b.id == broadcast_id and b.status == 'scheduled':
|
||||
b.status = 'skipped'
|
||||
if b._timer:
|
||||
b._timer.cancel()
|
||||
b._timer = None
|
||||
logger.info(
|
||||
"Skipped broadcast: %s at %s", b.content, b.utc_time
|
||||
)
|
||||
self._emit_event({
|
||||
'type': 'schedule_capture_skipped',
|
||||
'broadcast': b.to_dict(),
|
||||
'reason': 'manual',
|
||||
})
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
"""Get current scheduler status."""
|
||||
with self._lock:
|
||||
return {
|
||||
'enabled': self._enabled,
|
||||
'station': self._station,
|
||||
'callsign': self._callsign,
|
||||
'frequency_khz': self._frequency_khz,
|
||||
'device': self._device,
|
||||
'gain': self._gain,
|
||||
'ioc': self._ioc,
|
||||
'lpm': self._lpm,
|
||||
'scheduled_count': sum(
|
||||
1 for b in self._broadcasts if b.status == 'scheduled'
|
||||
),
|
||||
'total_broadcasts': len(self._broadcasts),
|
||||
}
|
||||
|
||||
def get_broadcasts(self) -> list[dict[str, Any]]:
|
||||
"""Get list of scheduled broadcasts."""
|
||||
with self._lock:
|
||||
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:
|
||||
"""Recompute broadcast schedule and set timers."""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
station_data = get_station(self._callsign)
|
||||
if not station_data:
|
||||
logger.error("Station %s not found during refresh", self._callsign)
|
||||
return
|
||||
|
||||
schedule = station_data.get('schedule', [])
|
||||
|
||||
with self._lock:
|
||||
# Cancel existing timers
|
||||
for b in self._broadcasts:
|
||||
if b._timer:
|
||||
b._timer.cancel()
|
||||
if b._stop_timer:
|
||||
b._stop_timer.cancel()
|
||||
|
||||
# Keep completed/skipped for history, replace scheduled
|
||||
history = [
|
||||
b for b in self._broadcasts
|
||||
if b.status in ('complete', 'skipped', 'capturing')
|
||||
]
|
||||
self._broadcasts = history
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
buffer = WEFAX_CAPTURE_BUFFER_SECONDS
|
||||
|
||||
for entry in schedule:
|
||||
utc_time = entry.get('utc', '')
|
||||
duration_min = entry.get('duration_min', 20)
|
||||
content = entry.get('content', '')
|
||||
|
||||
parts = utc_time.split(':')
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
|
||||
try:
|
||||
hour = int(parts[0])
|
||||
minute = int(parts[1])
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Compute next occurrence (today or tomorrow)
|
||||
broadcast_dt = now.replace(
|
||||
hour=hour, minute=minute, second=0, microsecond=0
|
||||
)
|
||||
capture_end = broadcast_dt + timedelta(
|
||||
minutes=duration_min, seconds=buffer
|
||||
)
|
||||
|
||||
# If the broadcast end is already past, schedule for tomorrow
|
||||
if capture_end <= now:
|
||||
broadcast_dt += timedelta(days=1)
|
||||
capture_end = broadcast_dt + timedelta(
|
||||
minutes=duration_min, seconds=buffer
|
||||
)
|
||||
|
||||
capture_start = broadcast_dt - timedelta(seconds=buffer)
|
||||
occurrence_date = broadcast_dt.date().isoformat()
|
||||
|
||||
# Check if this specific day/slot was already processed.
|
||||
history_key = self._history_key(
|
||||
self._callsign,
|
||||
utc_time,
|
||||
occurrence_date,
|
||||
)
|
||||
if any(
|
||||
self._history_key(
|
||||
h.callsign,
|
||||
h.utc_time,
|
||||
getattr(h, 'occurrence_date', ''),
|
||||
) == history_key
|
||||
for h in history
|
||||
):
|
||||
continue
|
||||
|
||||
sb = ScheduledBroadcast(
|
||||
station=self._station,
|
||||
callsign=self._callsign,
|
||||
frequency_khz=self._frequency_khz,
|
||||
utc_time=utc_time,
|
||||
duration_min=duration_min,
|
||||
content=content,
|
||||
occurrence_date=occurrence_date,
|
||||
)
|
||||
|
||||
# Schedule capture timer
|
||||
delay = max(0.0, (capture_start - now).total_seconds())
|
||||
sb._timer = threading.Timer(
|
||||
delay, self._execute_capture, args=[sb]
|
||||
)
|
||||
sb._timer.daemon = True
|
||||
sb._timer.start()
|
||||
|
||||
logger.info(
|
||||
"Scheduled capture: %s at %s UTC (fires in %.0fs)",
|
||||
content, utc_time, delay,
|
||||
)
|
||||
|
||||
self._broadcasts.append(sb)
|
||||
|
||||
logger.info(
|
||||
"WeFax scheduler refreshed: %d broadcasts scheduled",
|
||||
sum(1 for b in self._broadcasts if b.status == 'scheduled'),
|
||||
)
|
||||
|
||||
# Schedule next refresh
|
||||
if self._refresh_timer:
|
||||
self._refresh_timer.cancel()
|
||||
self._refresh_timer = threading.Timer(
|
||||
WEFAX_SCHEDULE_REFRESH_MINUTES * 60,
|
||||
self._refresh_schedule,
|
||||
)
|
||||
self._refresh_timer.daemon = True
|
||||
self._refresh_timer.start()
|
||||
|
||||
def _execute_capture(self, sb: ScheduledBroadcast) -> None:
|
||||
"""Execute capture for a scheduled broadcast (with error guard)."""
|
||||
logger.info("Timer fired for broadcast: %s at %s", sb.content, sb.utc_time)
|
||||
try:
|
||||
self._execute_capture_inner(sb)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Unhandled exception in scheduled capture: %s at %s",
|
||||
sb.content, sb.utc_time,
|
||||
)
|
||||
sb.status = 'skipped'
|
||||
self._emit_event({
|
||||
'type': 'schedule_capture_skipped',
|
||||
'broadcast': sb.to_dict(),
|
||||
'reason': 'error',
|
||||
'detail': 'internal error — see server logs',
|
||||
})
|
||||
|
||||
def _execute_capture_inner(self, sb: ScheduledBroadcast) -> None:
|
||||
"""Execute capture for a scheduled broadcast."""
|
||||
if not self._enabled or sb.status != 'scheduled':
|
||||
return
|
||||
|
||||
decoder = get_wefax_decoder()
|
||||
|
||||
if decoder.is_running:
|
||||
logger.info("Decoder busy, skipping scheduled broadcast: %s", sb.content)
|
||||
sb.status = 'skipped'
|
||||
self._emit_event({
|
||||
'type': 'schedule_capture_skipped',
|
||||
'broadcast': sb.to_dict(),
|
||||
'reason': 'decoder_busy',
|
||||
})
|
||||
return
|
||||
|
||||
# Claim SDR device
|
||||
try:
|
||||
import app as app_module
|
||||
error = app_module.claim_sdr_device(self._device, 'wefax')
|
||||
if error:
|
||||
logger.info(
|
||||
"SDR device busy, skipping: %s - %s", sb.content, error
|
||||
)
|
||||
sb.status = 'skipped'
|
||||
self._emit_event({
|
||||
'type': 'schedule_capture_skipped',
|
||||
'broadcast': sb.to_dict(),
|
||||
'reason': 'device_busy',
|
||||
})
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
sb.status = 'capturing'
|
||||
|
||||
def _release_device():
|
||||
try:
|
||||
import app as app_module
|
||||
app_module.release_sdr_device(self._device)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
released = False
|
||||
release_lock = threading.Lock()
|
||||
|
||||
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(
|
||||
frequency_khz=self._frequency_khz,
|
||||
station=self._callsign,
|
||||
device_index=self._device,
|
||||
gain=self._gain,
|
||||
ioc=self._ioc,
|
||||
lpm=self._lpm,
|
||||
direct_sampling=self._direct_sampling,
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info("Auto-scheduler started capture: %s", sb.content)
|
||||
self._emit_event({
|
||||
'type': 'schedule_capture_start',
|
||||
'broadcast': sb.to_dict(),
|
||||
})
|
||||
|
||||
# Schedule stop timer at broadcast end + buffer
|
||||
now = datetime.now(timezone.utc)
|
||||
parts = sb.utc_time.split(':')
|
||||
broadcast_dt = now.replace(
|
||||
hour=int(parts[0]), minute=int(parts[1]),
|
||||
second=0, microsecond=0,
|
||||
)
|
||||
if broadcast_dt < now - timedelta(hours=1):
|
||||
broadcast_dt += timedelta(days=1)
|
||||
stop_dt = broadcast_dt + timedelta(
|
||||
minutes=sb.duration_min,
|
||||
seconds=WEFAX_CAPTURE_BUFFER_SECONDS,
|
||||
)
|
||||
stop_delay = max(0.0, (stop_dt - now).total_seconds())
|
||||
|
||||
if stop_delay > 0:
|
||||
sb._stop_timer = threading.Timer(
|
||||
stop_delay, self._stop_capture, args=[sb, _release_device_once]
|
||||
)
|
||||
sb._stop_timer.daemon = True
|
||||
sb._stop_timer.start()
|
||||
else:
|
||||
# If execution was delayed beyond end-of-window, close out
|
||||
# immediately so SDR allocation is never stranded.
|
||||
logger.warning(
|
||||
"Capture window already elapsed for %s at %s UTC; stopping immediately",
|
||||
sb.content,
|
||||
sb.utc_time,
|
||||
)
|
||||
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(
|
||||
self, sb: ScheduledBroadcast, release_fn: Callable
|
||||
) -> None:
|
||||
"""Stop capture at broadcast end."""
|
||||
if sb.status != 'capturing':
|
||||
release_fn()
|
||||
return
|
||||
|
||||
sb.status = 'complete'
|
||||
|
||||
decoder = get_wefax_decoder()
|
||||
if decoder.is_running:
|
||||
decoder.stop()
|
||||
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:
|
||||
"""Emit scheduler event to callback."""
|
||||
if self._event_callback:
|
||||
try:
|
||||
self._event_callback(event)
|
||||
except Exception as e:
|
||||
logger.error("Error in scheduler event callback: %s", e)
|
||||
|
||||
|
||||
# Singleton
|
||||
_scheduler: WeFaxScheduler | None = None
|
||||
_scheduler_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_wefax_scheduler() -> WeFaxScheduler:
|
||||
"""Get or create the global WeFax scheduler instance."""
|
||||
global _scheduler
|
||||
if _scheduler is None:
|
||||
with _scheduler_lock:
|
||||
if _scheduler is None:
|
||||
_scheduler = WeFaxScheduler()
|
||||
return _scheduler
|
||||
@@ -0,0 +1,160 @@
|
||||
"""WeFax station database loader.
|
||||
|
||||
Loads and caches station data from data/wefax_stations.json. Provides
|
||||
lookup by callsign and current-broadcast filtering based on UTC time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
_stations_cache: list[dict] | None = None
|
||||
_stations_by_callsign: dict[str, dict] = {}
|
||||
_VALID_FREQUENCY_REFERENCES = {'auto', 'carrier', 'dial'}
|
||||
WEFAX_USB_ALIGNMENT_OFFSET_KHZ = 1.9
|
||||
|
||||
_STATIONS_PATH = Path(__file__).resolve().parent.parent / 'data' / 'wefax_stations.json'
|
||||
|
||||
|
||||
def load_stations() -> list[dict]:
|
||||
"""Load all WeFax stations from JSON, caching on first call."""
|
||||
global _stations_cache, _stations_by_callsign
|
||||
|
||||
if _stations_cache is not None:
|
||||
return _stations_cache
|
||||
|
||||
with open(_STATIONS_PATH) as f:
|
||||
data = json.load(f)
|
||||
|
||||
_stations_cache = data.get('stations', [])
|
||||
_stations_by_callsign = {s['callsign']: s for s in _stations_cache}
|
||||
return _stations_cache
|
||||
|
||||
|
||||
def get_station(callsign: str) -> dict | None:
|
||||
"""Get a single station by callsign."""
|
||||
load_stations()
|
||||
return _stations_by_callsign.get(callsign.upper())
|
||||
|
||||
|
||||
def _normalize_frequency_reference(value: str | None) -> str:
|
||||
"""Normalize and validate frequency reference token."""
|
||||
reference = str(value or 'auto').strip().lower()
|
||||
if reference not in _VALID_FREQUENCY_REFERENCES:
|
||||
choices = ', '.join(sorted(_VALID_FREQUENCY_REFERENCES))
|
||||
raise ValueError(f'frequency_reference must be one of: {choices}')
|
||||
return reference
|
||||
|
||||
|
||||
def _station_frequency_reference(station: dict, listed_frequency_khz: float) -> str:
|
||||
"""Infer whether a station frequency entry is carrier or already USB dial."""
|
||||
for entry in station.get('frequencies', []):
|
||||
try:
|
||||
entry_khz = float(entry.get('khz'))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if abs(entry_khz - listed_frequency_khz) > 0.001:
|
||||
continue
|
||||
entry_ref = str(entry.get('reference', '')).strip().lower()
|
||||
if entry_ref in ('carrier', 'dial'):
|
||||
return entry_ref
|
||||
|
||||
station_ref = str(station.get('frequency_reference', '')).strip().lower()
|
||||
if station_ref in ('carrier', 'dial'):
|
||||
return station_ref
|
||||
|
||||
# Most published marine WeFax channel lists are carrier frequencies.
|
||||
return 'carrier'
|
||||
|
||||
|
||||
def resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz: float,
|
||||
station_callsign: str = '',
|
||||
frequency_reference: str = 'auto',
|
||||
) -> tuple[float, str, bool]:
|
||||
"""Resolve listed frequency to the actual USB dial frequency.
|
||||
|
||||
Args:
|
||||
listed_frequency_khz: Frequency value provided by UI/API.
|
||||
station_callsign: Station callsign used for metadata lookup.
|
||||
frequency_reference: One of auto/carrier/dial.
|
||||
|
||||
Returns:
|
||||
(tuned_frequency_khz, resolved_reference, offset_applied)
|
||||
"""
|
||||
listed = float(listed_frequency_khz)
|
||||
if listed <= 0:
|
||||
raise ValueError('frequency_khz must be greater than zero')
|
||||
|
||||
requested_ref = _normalize_frequency_reference(frequency_reference)
|
||||
resolved_ref = requested_ref
|
||||
|
||||
if requested_ref == 'auto':
|
||||
station = get_station(station_callsign) if station_callsign else None
|
||||
if station:
|
||||
resolved_ref = _station_frequency_reference(station, listed)
|
||||
else:
|
||||
# For ad-hoc frequencies (no station metadata), treat input as dial.
|
||||
resolved_ref = 'dial'
|
||||
|
||||
if resolved_ref == 'carrier':
|
||||
tuned = round(listed - WEFAX_USB_ALIGNMENT_OFFSET_KHZ, 3)
|
||||
if tuned <= 0:
|
||||
raise ValueError('frequency_khz too low after USB alignment offset')
|
||||
return tuned, resolved_ref, True
|
||||
|
||||
return listed, resolved_ref, False
|
||||
|
||||
|
||||
def get_current_broadcasts(callsign: str) -> list[dict]:
|
||||
"""Return schedule entries closest to the current UTC time.
|
||||
|
||||
Returns up to 3 entries: the most recent past broadcast and the
|
||||
next two upcoming ones, annotated with ``minutes_until`` or
|
||||
``minutes_ago`` relative to now.
|
||||
"""
|
||||
station = get_station(callsign)
|
||||
if not station:
|
||||
return []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
current_minutes = now.hour * 60 + now.minute
|
||||
|
||||
schedule = station.get('schedule', [])
|
||||
if not schedule:
|
||||
return []
|
||||
|
||||
# Convert schedule times to minutes-since-midnight for comparison
|
||||
entries: list[tuple[int, dict]] = []
|
||||
for entry in schedule:
|
||||
parts = entry['utc'].split(':')
|
||||
mins = int(parts[0]) * 60 + int(parts[1])
|
||||
entries.append((mins, entry))
|
||||
entries.sort(key=lambda x: x[0])
|
||||
|
||||
# Find closest entries relative to now
|
||||
results = []
|
||||
for mins, entry in entries:
|
||||
diff = mins - current_minutes
|
||||
# Wrap around midnight
|
||||
if diff < -720:
|
||||
diff += 1440
|
||||
elif diff > 720:
|
||||
diff -= 1440
|
||||
|
||||
annotated = dict(entry)
|
||||
if diff >= 0:
|
||||
annotated['minutes_until'] = diff
|
||||
else:
|
||||
annotated['minutes_ago'] = abs(diff)
|
||||
annotated['_sort_key'] = abs(diff)
|
||||
results.append(annotated)
|
||||
|
||||
results.sort(key=lambda x: x['_sort_key'])
|
||||
|
||||
# Return 3 nearest entries, clean up sort key
|
||||
for r in results:
|
||||
r.pop('_sort_key', None)
|
||||
return results[:3]
|
||||
Reference in New Issue
Block a user