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:
mitchross
2026-02-26 00:37:02 -05:00
71 changed files with 13181 additions and 3658 deletions
+42
View File
@@ -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.
+11 -1
View File
@@ -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
+9 -1
View File
@@ -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)
+733
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+21 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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,
})
+33
View File
@@ -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);
+50 -1
View File
@@ -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%);
+118
View File
@@ -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;
}
+687
View File
@@ -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;
}
}
+50
View File
@@ -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

+9 -1
View File
@@ -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);
};
+13 -1
View File
@@ -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;
}
+7 -4
View File
@@ -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,
},
});
}
+39 -2
View File
@@ -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();
});
+7 -1
View File
@@ -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
View File
@@ -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 {
+400
View File
@@ -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
View File
@@ -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) {
+5 -2
View File
@@ -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) {
+28 -2
View File
@@ -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
View File
@@ -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 = '&#9654; 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 = '&#9654; 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 = '&#9654; 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
View File
@@ -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
+98
View File
@@ -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>
+26 -14
View File
@@ -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();
+129
View File
@@ -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&ndash;30 MHz) &mdash; 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 &mdash; 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;">&plusmn;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>
+4
View File
@@ -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 #}
+11
View File
@@ -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>
+6 -1
View File
@@ -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);
}
}
}
+51
View File
@@ -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
View File
@@ -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
+78
View File
@@ -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
+393
View File
@@ -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')
+57
View File
@@ -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
View File
@@ -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
+20 -12
View File
@@ -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."""
+121
View File
@@ -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
+4 -4
View File
@@ -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 = {
+3 -3
View File
@@ -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()
+591
View File
@@ -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
+159
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+16 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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),
+6 -2
View File
@@ -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),
+6 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+163 -89
View File
@@ -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."""
+95 -78
View File
@@ -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
View File
@@ -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
+543
View File
@@ -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
+160
View File
@@ -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]