mirror of
https://github.com/smittix/intercept.git
synced 2026-06-30 14:08:33 -07:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 386b95a25d | |||
| 753a08234e | |||
| 276b151e9e | |||
| 30450295b5 | |||
| 47c0fcbefa | |||
| 2ec5085673 | |||
| 379b6a9667 | |||
| 5cff7de117 | |||
| 0588055d1f | |||
| e14271c5ee | |||
| e38b8fb464 | |||
| a1b1e5a77e | |||
| 5d3811cc60 | |||
| 8813d069bc | |||
| cdb5285b68 | |||
| 67847eb708 | |||
| c870f118bf | |||
| a202c9dd94 | |||
| 07887b7c99 | |||
| 0af3028151 | |||
| 5e996654fe | |||
| 320fe82348 | |||
| 1c72e15c7c | |||
| 74d5663f73 | |||
| f4a9cb7da6 | |||
| 2f6afd5e28 | |||
| 9463d53763 | |||
| c177dd354a | |||
| d4652017f5 | |||
| b68a53eb53 | |||
| d68d1ec53a | |||
| 9c15ece508 | |||
| fe222c0393 | |||
| 68cafe8cd0 | |||
| d01742678c | |||
| 31ae70b8fa | |||
| e7f13a5856 | |||
| a9ed367148 | |||
| 2505218385 | |||
| b5c35890af | |||
| 484d9ce21b | |||
| 9353527e1b | |||
| fd3ad63971 | |||
| 2e583649d0 | |||
| a3c509aa94 | |||
| f26a820b1d | |||
| 901e7f95e8 | |||
| 592d11aae2 | |||
| 30a0085f1d | |||
| b30d883974 |
@@ -15,12 +15,21 @@ jobs:
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
group: ["a-l", "m-z"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: pip install -r requirements-dev.txt
|
||||
- name: Run tests
|
||||
run: pytest --tb=short -q
|
||||
- name: Run tests (${{ matrix.group }})
|
||||
run: |
|
||||
if [ "${{ matrix.group }}" = "a-l" ]; then
|
||||
pytest tests/test_[a-l]*.py --tb=short -q
|
||||
else
|
||||
pytest tests/test_[m-z]*.py --tb=short -q
|
||||
fi
|
||||
continue-on-error: true
|
||||
|
||||
@@ -158,10 +158,21 @@ Each signal type has its own Flask blueprint:
|
||||
| direwolf | APRS | TNC modem for packet radio |
|
||||
|
||||
### Frontend Structure
|
||||
- **UI direction (decided 2026-06-12)**: map-heavy modes get dedicated dashboard
|
||||
pages (`/adsb/dashboard`, `/ais/dashboard`, `/satellite/dashboard`); the SPA
|
||||
in `index.html` keeps text/scan modes. APRS and Meshtastic are map-centric
|
||||
and should migrate to dashboards under their own plans — do not grow their
|
||||
SPA footprint.
|
||||
- **Templates**: `templates/index.html` (main SPA), `templates/partials/modes/*.html` (sidebar panels), `templates/partials/nav.html` (global nav)
|
||||
- **JS Modules**: `static/js/modes/*.js` - IIFE pattern per mode (e.g., `WeatherSat`, `SSTV`, `Meshtastic`)
|
||||
- **CSS**: `static/css/modes/*.css` - scoped styles per mode, CSS variables for theming (`--bg-card`, `--accent-cyan`, `--font-mono`)
|
||||
- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()`
|
||||
- **Mode Integration**: Each mode is declared once in `static/js/mode-registry.js`
|
||||
(label, group, elementId, module, init/destroy hooks, visuals flag). The
|
||||
catalog, sidebar toggles, destroy map, visuals list, and init dispatch in
|
||||
`templates/index.html` are all derived from it. A new mode additionally needs:
|
||||
its partial in `templates/partials/modes/`, entries in the CSS/JS lazy-load
|
||||
asset maps in `index.html`, and its include in the partials block.
|
||||
`tests/test_mode_registry.py` enforces registry/asset consistency.
|
||||
|
||||
### Docker
|
||||
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.). CMD runs `start.sh` (gunicorn + gevent)
|
||||
|
||||
@@ -1293,6 +1293,11 @@ def _init_app() -> None:
|
||||
except Exception as e:
|
||||
logger.warning(f"Ground station scheduler init failed: {e}")
|
||||
|
||||
# Skip background init when disabled (set by tests — the deferred thread
|
||||
# fires mid-session and its subprocess/DB cleanup races with test mocks)
|
||||
if os.environ.get("INTERCEPT_SKIP_DEFERRED_INIT") == "1":
|
||||
return
|
||||
|
||||
threading.Thread(target=_deferred_init, daemon=True).start()
|
||||
|
||||
|
||||
|
||||
+12
-12
@@ -4,8 +4,8 @@
|
||||
TLE_SATELLITES = {
|
||||
"ISS": (
|
||||
"ISS (ZARYA)",
|
||||
"1 25544U 98067A 26140.52007258 .00005164 00000+0 10084-3 0 9993",
|
||||
"2 25544 51.6328 77.0641 0007497 79.3410 280.8422 15.49283153567468",
|
||||
"1 25544U 98067A 23321.52083333 .00016717 00000-0 30171-3 0 9992",
|
||||
"2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123456",
|
||||
),
|
||||
"NOAA-15": (
|
||||
"NOAA 15",
|
||||
@@ -24,27 +24,27 @@ TLE_SATELLITES = {
|
||||
),
|
||||
"NOAA-20": (
|
||||
"NOAA 20 (JPSS-1)",
|
||||
"1 43013U 17073A 26140.44110773 .00000055 00000+0 46930-4 0 9994",
|
||||
"2 43013 98.7764 80.1520 0001265 43.4537 316.6738 14.19505991440534",
|
||||
"1 43013U 17073A 26141.21646093 .00000052 00000+0 45436-4 0 9996",
|
||||
"2 43013 98.7764 80.9203 0001233 42.6389 317.4882 14.19506117440643",
|
||||
),
|
||||
"NOAA-21": (
|
||||
"NOAA 21 (JPSS-2)",
|
||||
"1 54234U 22150A 26140.47502274 .00000020 00000+0 29984-4 0 9999",
|
||||
"2 54234 98.7052 79.7311 0000538 296.4939 63.6182 14.19559760182618",
|
||||
"1 54234U 22150A 26141.25034758 .00000025 00000+0 32664-4 0 9997",
|
||||
"2 54234 98.7052 80.4933 0000516 290.1874 69.9247 14.19559916182728",
|
||||
),
|
||||
"METEOR-M2": (
|
||||
"METEOR-M 2",
|
||||
"1 40069U 14037A 26140.48222780 .00000329 00000+0 16961-3 0 9999",
|
||||
"2 40069 98.5104 117.2052 0006833 111.5029 248.6878 14.21453950615385",
|
||||
"1 40069U 14037A 26141.25652306 .00000366 00000+0 18646-3 0 9999",
|
||||
"2 40069 98.5106 117.9520 0006860 109.5984 250.5935 14.21454410615491",
|
||||
),
|
||||
"METEOR-M2-3": (
|
||||
"METEOR-M2 3",
|
||||
"1 57166U 23091A 26140.55562749 -.00000013 00000+0 13331-4 0 9995",
|
||||
"2 57166 98.6097 196.0965 0002883 242.0522 118.0365 14.24044155150583",
|
||||
"1 57166U 23091A 26141.32851392 -.00000014 00000+0 12575-4 0 9996",
|
||||
"2 57166 98.6097 196.8537 0002910 239.0757 121.0137 14.24044204150691",
|
||||
),
|
||||
"METEOR-M2-4": (
|
||||
"METEOR-M2 4",
|
||||
"1 59051U 24039A 26140.53898488 .00000003 00000+0 20858-4 0 9993",
|
||||
"2 59051 98.6996 100.1874 0005955 247.0139 113.0410 14.22426327115336",
|
||||
"1 59051U 24039A 26141.24240655 .00000007 00000+0 22827-4 0 9991",
|
||||
"2 59051 98.6997 100.8818 0005969 244.5272 115.5289 14.22426426115439",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# Pager & 433 Sensor Display Revamp
|
||||
|
||||
**Date:** 2026-05-21
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the plain chronological card feed for the Pager and 433 Sensor modes with purpose-built views that better surface the structure of each signal type. Both new views are opt-out (toggle to classic feed available).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
The two modes use slightly different DOM strategies suited to each layout.
|
||||
|
||||
**Pager:** `#pagerDirectoryView` is the left directory panel only. The output panel parent switches to `display: flex` in directory mode, placing the directory panel and `#output` side by side. `#output` becomes the right feed panel — no duplication, no hidden copy.
|
||||
|
||||
**Sensor:** `#sensorDashboardView` is a full-replacement grid that sits alongside `#output`. In dashboard mode `#output` is hidden but continues to receive classic `signal-card` insertions so export and filtering remain intact.
|
||||
|
||||
```
|
||||
[output-panel] (flex in pager directory mode)
|
||||
[#pagerDirectoryView] ← left dir panel only; shown in pager directory mode
|
||||
[#sensorDashboardView] ← full replacement grid; shown in sensor dashboard mode
|
||||
[#output] ← right feed panel (pager) or hidden (sensor); always updated
|
||||
```
|
||||
|
||||
`addMessage()` gets a hook to `PagerDirectory.addMessage()` for directory panel updates only (the feed is `#output` itself). `addSensorReading()` gets a hook to `SensorDashboard.addReading()` for station card updates. No other existing logic changes.
|
||||
|
||||
### New files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `static/js/components/pager-directory.js` | PagerDirectory component |
|
||||
| `static/js/components/sensor-dashboard.js` | SensorDashboard component |
|
||||
| `static/css/components/pager-directory.css` | Directory view styles |
|
||||
| `static/css/components/sensor-dashboard.css` | Dashboard view styles |
|
||||
|
||||
`templates/index.html` gets:
|
||||
- Two new sibling containers (`#pagerDirectoryView`, `#sensorDashboardView`)
|
||||
- Toggle buttons in the output panel header (one per mode, shown when that mode is active)
|
||||
- Script/link tags for the four new files
|
||||
- One-line hook calls inside `addMessage()` and `addSensorReading()`
|
||||
|
||||
---
|
||||
|
||||
## Pager — Source Directory View
|
||||
|
||||
### Layout
|
||||
|
||||
Split panel, full height of the output area:
|
||||
|
||||
- **Left (200 px fixed):** address directory panel
|
||||
- **Right (flex):** full message feed
|
||||
|
||||
### Directory panel (left)
|
||||
|
||||
- One row per unique pager address seen this session
|
||||
- Sorted by message count descending (most active at top)
|
||||
- Each row shows:
|
||||
- Protocol badge (`P` = POCSAG, `F` = FLEX), coloured accordingly
|
||||
- Address string
|
||||
- Message count (`×24`)
|
||||
- Relative-width activity bar (count relative to the highest-count address)
|
||||
- Last-seen relative timestamp (`just now`, `2m ago`)
|
||||
- Green dot when a new message arrives from that address (fades after 3 s)
|
||||
- Blue left-border accent on the currently highlighted address
|
||||
- Directory state is in-memory for the session only (not persisted)
|
||||
|
||||
### Feed panel (right)
|
||||
|
||||
- Shows **all messages** at all times (no filtering)
|
||||
- When an address is highlighted via the directory:
|
||||
- Feed scrolls to that address's most recent card
|
||||
- All cards from that address get a blue left-border + subtle background tint
|
||||
- Sub-header shows `"<address> highlighted"` with a "clear highlight" link
|
||||
- Clicking "clear highlight" (or clicking the same address again) removes all highlighting and returns to the plain feed
|
||||
- Cards are otherwise identical to the existing `signal-card` format
|
||||
|
||||
### Toggle
|
||||
|
||||
- Button group top-right of the output panel header: **Directory** | **Feed**
|
||||
- Default: **Directory**
|
||||
- Preference saved to `localStorage` key `pagerView` (`'directory'` | `'feed'`)
|
||||
- Restored on mode switch
|
||||
|
||||
---
|
||||
|
||||
## 433 Sensor — Station Dashboard View
|
||||
|
||||
### Layout
|
||||
|
||||
Responsive CSS grid of station cards (3 columns on typical desktop width, wrapping as needed).
|
||||
|
||||
### Station card
|
||||
|
||||
One persistent card per unique device, keyed by `model + id`. Cards are created on first reading and updated in place on subsequent readings from the same device.
|
||||
|
||||
Each card contains:
|
||||
|
||||
- **Header:** device model name (e.g. `Acurite-Tower`), device ID + channel, last-seen relative timestamp (green when < 10 s)
|
||||
- **Readings:** the primary numeric values for that device (temperature, humidity, pressure, wind speed, rain, etc.) — label + value + unit, displayed as a small inline grid
|
||||
- **Sparkline:** SVG polyline tracking the primary numeric value across the last 30 readings. Colour matches the reading type (amber for temperature, blue for humidity/wind, purple for pressure). A filled circle marks the latest data point.
|
||||
- **Footer:** battery status (green `BAT OK` / red `BAT LOW`), SNR value, frequency badge
|
||||
|
||||
### State-only devices
|
||||
|
||||
Devices that emit only a state (doorbells, PIR sensors, etc.) get a card with a state indicator (coloured dot + label e.g. `MOTION DETECTED`) in place of numeric readings. The sparkline area is replaced with an "event-only device" label. Card still flashes on each event.
|
||||
|
||||
### Flash on update
|
||||
|
||||
When a new reading arrives for a known device:
|
||||
- Card receives a CSS animation class that briefly tints the background (blue for temp sensors, purple for other types) and fades back to normal over ~0.8 s
|
||||
- Values update in place; the sparkline dot advances right
|
||||
|
||||
### New device appearance
|
||||
|
||||
First time a device is seen: card slides in with a subtle green border accent. The border fades to normal after the first update.
|
||||
|
||||
### Toggle
|
||||
|
||||
- Button group top-right of output panel header: **Dashboard** | **Feed**
|
||||
- Default: **Dashboard**
|
||||
- Preference saved to `localStorage` key `sensorView` (`'dashboard'` | `'feed'`)
|
||||
- Restored on mode switch
|
||||
|
||||
---
|
||||
|
||||
## Shared behaviour
|
||||
|
||||
- Both toggles are shown only when the relevant mode is active
|
||||
- Classic `#output` feed always receives cards in the background (export, CSV/JSON, existing filter bar all continue to work)
|
||||
- No changes to SSE handling, process management, or backend routes
|
||||
- No new backend endpoints required
|
||||
File diff suppressed because it is too large
Load Diff
+1172
-1401
File diff suppressed because it is too large
Load Diff
+17
-4
@@ -27,13 +27,16 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"flask>=3.0.0",
|
||||
"flask-wtf>=1.2.0",
|
||||
"flask-compress>=1.15",
|
||||
"flask-limiter>=2.5.4",
|
||||
"flask-sock",
|
||||
"simple-websocket>=0.5.1",
|
||||
"websocket-client>=1.6.0",
|
||||
"skyfield>=1.45",
|
||||
"pyserial>=3.5",
|
||||
"Werkzeug>=3.1.5",
|
||||
"flask-limiter>=2.5.4",
|
||||
"bleak>=0.21.0",
|
||||
"flask-sock",
|
||||
"websocket-client>=1.6.0",
|
||||
"requests>=2.28.0",
|
||||
]
|
||||
|
||||
@@ -51,6 +54,7 @@ dev = [
|
||||
"black>=23.0.0",
|
||||
"mypy>=1.0.0",
|
||||
"types-flask>=1.1.0",
|
||||
"pre-commit>=3.0.0",
|
||||
]
|
||||
|
||||
optionals = [
|
||||
@@ -59,8 +63,13 @@ optionals = [
|
||||
"numpy>=1.24.0",
|
||||
"Pillow>=9.0.0",
|
||||
"meshtastic>=2.0.0",
|
||||
"meshcore>=2.3.0",
|
||||
"psycopg2-binary>=2.9.9",
|
||||
"scapy>=2.4.5",
|
||||
"cryptography>=41.0.0",
|
||||
"psutil>=5.9.0",
|
||||
"gunicorn>=21.2.0",
|
||||
"gevent>=23.9.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -151,7 +160,11 @@ exclude = [
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = "-v --tb=short"
|
||||
# 'live' tests drive real SDR hardware — run explicitly with: pytest -m live
|
||||
addopts = "-v --tb=short -m 'not live'"
|
||||
markers = [
|
||||
"live: tests that require real SDR hardware and run live decoders",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["app", "routes", "utils", "data"]
|
||||
|
||||
+2
-1
@@ -27,7 +27,8 @@ pyserial>=3.5
|
||||
|
||||
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
|
||||
meshtastic>=2.0.0
|
||||
meshcore>=1.0.0
|
||||
# meshcore 2.3.0+ required for EventType.STATS_CORE; needs Python 3.10+
|
||||
meshcore>=2.3.0
|
||||
|
||||
# Deauthentication attack detection (optional - for WiFi TSCM)
|
||||
scapy>=2.4.5
|
||||
|
||||
+3
-1
@@ -1786,7 +1786,9 @@ def aircraft_photo(registration: str):
|
||||
try:
|
||||
# Planespotters.net public API
|
||||
url = f"https://api.planespotters.net/pub/photos/reg/{registration}"
|
||||
resp = requests.get(url, timeout=5, headers={"User-Agent": "INTERCEPT-ADS-B/1.0"})
|
||||
resp = requests.get(
|
||||
url, timeout=5, headers={"User-Agent": "INTERCEPT-ADS-B/2.27 (+https://github.com/smittix/intercept)"}
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
|
||||
@@ -186,6 +186,19 @@ def load_seen_device_ids() -> set[str]:
|
||||
return {row["device_id"] for row in cursor}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPERS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def device_to_dict(device: BTDeviceAggregate) -> dict:
|
||||
"""Serialize a BTDeviceAggregate to a JSON-safe dict with heuristics flattened to top level."""
|
||||
d = device.to_dict()
|
||||
heuristics = d.pop("heuristics", {})
|
||||
d.update(heuristics)
|
||||
return d
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
+296
-315
File diff suppressed because it is too large
Load Diff
+71
-67
@@ -12,7 +12,7 @@ import requests
|
||||
from flask import Blueprint, Response, jsonify, make_response, render_template, request
|
||||
|
||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||
from data.satellites import TLE_SATELLITES
|
||||
from utils import tle_store
|
||||
from utils.database import (
|
||||
add_tracked_satellite,
|
||||
bulk_add_tracked_satellites,
|
||||
@@ -47,8 +47,11 @@ MAX_RESPONSE_SIZE = 1024 * 1024
|
||||
# Allowed hosts for TLE fetching
|
||||
ALLOWED_TLE_HOSTS = ["celestrak.org", "celestrak.com", "www.celestrak.org", "www.celestrak.com"]
|
||||
|
||||
# Local TLE cache (can be updated via API)
|
||||
_tle_cache = dict(TLE_SATELLITES)
|
||||
|
||||
def _get_tle_cache() -> dict:
|
||||
"""All TLEs from the unified store."""
|
||||
return tle_store.all_tles()
|
||||
|
||||
|
||||
# Ground track cache: key=(sat_name, tle_line1[:20]) -> (track_data, computed_at_timestamp)
|
||||
# TTL is 1800 seconds (30 minutes)
|
||||
@@ -72,27 +75,26 @@ _BUILTIN_NORAD_TO_KEY = {
|
||||
|
||||
|
||||
def _load_db_satellites_into_cache():
|
||||
"""Load user-tracked satellites from DB into the TLE cache."""
|
||||
global _tle_cache
|
||||
"""Load user-tracked satellites from DB into the TLE store."""
|
||||
try:
|
||||
db_sats = get_tracked_satellites()
|
||||
loaded = 0
|
||||
new_entries: dict = {}
|
||||
current = _get_tle_cache()
|
||||
for sat in db_sats:
|
||||
if sat["tle_line1"] and sat["tle_line2"]:
|
||||
# Use a cache key derived from name (sanitised)
|
||||
cache_key = sat["name"].replace(" ", "-").upper()
|
||||
if cache_key not in _tle_cache:
|
||||
_tle_cache[cache_key] = (sat["name"], sat["tle_line1"], sat["tle_line2"])
|
||||
loaded += 1
|
||||
if loaded:
|
||||
logger.info(f"Loaded {loaded} user-tracked satellites into TLE cache")
|
||||
if cache_key not in current:
|
||||
new_entries[cache_key] = (sat["name"], sat["tle_line1"], sat["tle_line2"])
|
||||
if new_entries:
|
||||
tle_store.update(new_entries)
|
||||
logger.info(f"Loaded {len(new_entries)} user-tracked satellites into TLE store")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load DB satellites into TLE cache: {e}")
|
||||
logger.warning(f"Failed to load DB satellites into TLE store: {e}")
|
||||
|
||||
|
||||
def get_cached_tle(name: str) -> tuple[str, str, str] | None:
|
||||
"""Return (name, line1, line2) from the live TLE cache, or None if not found."""
|
||||
return _tle_cache.get(name)
|
||||
"""Return (name, line1, line2) from the live TLE store, or None if not found."""
|
||||
return _get_tle_cache().get(name)
|
||||
|
||||
|
||||
def _normalize_satellite_name(value: object) -> str:
|
||||
@@ -118,7 +120,11 @@ def _get_tracked_satellite_maps() -> tuple[dict[int, dict], dict[str, dict]]:
|
||||
|
||||
|
||||
def _resolve_satellite_request(
|
||||
sat: object, tracked_by_norad: dict[int, dict], tracked_by_name: dict[str, dict]
|
||||
sat: object,
|
||||
tracked_by_norad: dict[int, dict],
|
||||
tracked_by_name: dict[str, dict],
|
||||
tles: dict,
|
||||
pending_tle_writes: dict,
|
||||
) -> tuple[str, int | None, tuple[str, str, str] | None]:
|
||||
"""Resolve a satellite request to display name, NORAD ID, and TLE data."""
|
||||
norad_id: int | None = None
|
||||
@@ -170,21 +176,23 @@ def _resolve_satellite_request(
|
||||
if norm in seen:
|
||||
continue
|
||||
seen.add(norm)
|
||||
if key in _tle_cache:
|
||||
tle_data = _tle_cache[key]
|
||||
if key in tles:
|
||||
tle_data = tles[key]
|
||||
break
|
||||
if norm in _tle_cache:
|
||||
tle_data = _tle_cache[norm]
|
||||
if norm in tles:
|
||||
tle_data = tles[norm]
|
||||
break
|
||||
|
||||
if tle_data is None and tracked and tracked.get("tle_line1") and tracked.get("tle_line2"):
|
||||
display_name = tracked.get("name") or sat_key or str(norad_id or "UNKNOWN")
|
||||
tle_data = (display_name, tracked["tle_line1"], tracked["tle_line2"])
|
||||
_tle_cache[_normalize_satellite_name(display_name)] = tle_data
|
||||
write_key = _normalize_satellite_name(display_name)
|
||||
pending_tle_writes[write_key] = tle_data
|
||||
tles[write_key] = tle_data
|
||||
|
||||
if tle_data is None and sat_key:
|
||||
normalized = _normalize_satellite_name(sat_key)
|
||||
for key, value in _tle_cache.items():
|
||||
for key, value in tles.items():
|
||||
if key == normalized or _normalize_satellite_name(value[0]) == normalized:
|
||||
tle_data = value
|
||||
break
|
||||
@@ -251,21 +259,20 @@ def _start_satellite_tracker():
|
||||
tle1 = sat_rec.get("tle_line1")
|
||||
tle2 = sat_rec.get("tle_line2")
|
||||
if not tle1 or not tle2:
|
||||
# Fall back to TLE cache. Try the builtin NORAD-ID key first
|
||||
# Fall back to TLE store. Try the builtin NORAD-ID key first
|
||||
# (e.g. 'ISS'), then the name-derived key as a last resort.
|
||||
try:
|
||||
norad_int = int(norad_id)
|
||||
except (TypeError, ValueError):
|
||||
norad_int = 0
|
||||
tles = _get_tle_cache()
|
||||
builtin_key = _BUILTIN_NORAD_TO_KEY.get(norad_int)
|
||||
cache_key = (
|
||||
builtin_key
|
||||
if (builtin_key and builtin_key in _tle_cache)
|
||||
else sat_name.replace(" ", "-").upper()
|
||||
builtin_key if (builtin_key and builtin_key in tles) else sat_name.replace(" ", "-").upper()
|
||||
)
|
||||
if cache_key not in _tle_cache:
|
||||
if cache_key not in tles:
|
||||
continue
|
||||
tle_entry = _tle_cache[cache_key]
|
||||
tle_entry = tles[cache_key]
|
||||
tle1 = tle_entry[1]
|
||||
tle2 = tle_entry[2]
|
||||
|
||||
@@ -521,6 +528,8 @@ def predict_passes():
|
||||
"METEOR-M2-4": "#00ff88",
|
||||
}
|
||||
tracked_by_norad, tracked_by_name = _get_tracked_satellite_maps()
|
||||
tles = _get_tle_cache()
|
||||
pending_tle_writes: dict = {}
|
||||
|
||||
resolved_satellites: list[tuple[str, int, tuple[str, str, str]]] = []
|
||||
for sat in sat_input:
|
||||
@@ -528,11 +537,19 @@ def predict_passes():
|
||||
sat,
|
||||
tracked_by_norad,
|
||||
tracked_by_name,
|
||||
tles,
|
||||
pending_tle_writes,
|
||||
)
|
||||
if not tle_data:
|
||||
continue
|
||||
resolved_satellites.append((sat_name, norad_id or 0, tle_data))
|
||||
|
||||
if pending_tle_writes:
|
||||
try:
|
||||
tle_store.update(pending_tle_writes)
|
||||
except Exception as e:
|
||||
logger.warning(f"TLE write-back failed (non-fatal): {e}")
|
||||
|
||||
if not resolved_satellites:
|
||||
return jsonify(
|
||||
{
|
||||
@@ -649,24 +666,29 @@ def get_satellite_position():
|
||||
now = None
|
||||
now_dt = None
|
||||
tracked_by_norad, tracked_by_name = _get_tracked_satellite_maps()
|
||||
tles = _get_tle_cache()
|
||||
pending_tle_writes: dict = {}
|
||||
|
||||
positions = []
|
||||
|
||||
for sat in sat_input:
|
||||
sat_name, norad_id, tle_data = _resolve_satellite_request(sat, tracked_by_norad, tracked_by_name)
|
||||
sat_name, norad_id, tle_data = _resolve_satellite_request(
|
||||
sat, tracked_by_norad, tracked_by_name, tles, pending_tle_writes
|
||||
)
|
||||
# Optional special handling for ISS. The dashboard does not enable this
|
||||
# because external API latency can make live updates stall.
|
||||
if prefer_realtime_api and (norad_id == 25544 or sat_name == "ISS"):
|
||||
iss_data = _fetch_iss_realtime(lat, lon)
|
||||
if iss_data:
|
||||
# Add orbit track if requested (using TLE for track prediction)
|
||||
if include_track and "ISS" in _tle_cache:
|
||||
iss_tle = _get_tle_cache().get("ISS")
|
||||
if include_track and iss_tle:
|
||||
try:
|
||||
if ts is None:
|
||||
ts = _get_timescale()
|
||||
now = ts.now()
|
||||
now_dt = now.utc_datetime()
|
||||
tle_data = _tle_cache["ISS"]
|
||||
tle_data = iss_tle
|
||||
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||
orbit_track = []
|
||||
for minutes_offset in range(-45, 46, 1):
|
||||
@@ -743,6 +765,12 @@ def get_satellite_position():
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if pending_tle_writes:
|
||||
try:
|
||||
tle_store.update(pending_tle_writes)
|
||||
except Exception as e:
|
||||
logger.warning(f"TLE write-back failed (non-fatal): {e}")
|
||||
|
||||
return jsonify({"status": "success", "positions": positions, "timestamp": datetime.utcnow().isoformat()})
|
||||
|
||||
|
||||
@@ -798,8 +826,6 @@ def refresh_tle_data() -> list:
|
||||
This can be called at startup or periodically to keep TLE data fresh.
|
||||
Returns list of satellite names that were updated.
|
||||
"""
|
||||
global _tle_cache
|
||||
|
||||
name_mappings = {
|
||||
"ISS (ZARYA)": "ISS",
|
||||
"NOAA 15": "NOAA-15",
|
||||
@@ -813,6 +839,8 @@ def refresh_tle_data() -> list:
|
||||
}
|
||||
|
||||
updated = []
|
||||
new_entries: dict = {}
|
||||
current = _get_tle_cache()
|
||||
|
||||
for group in ["stations", "weather", "noaa"]:
|
||||
url = f"https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=tle"
|
||||
@@ -833,8 +861,8 @@ def refresh_tle_data() -> list:
|
||||
|
||||
internal_name = name_mappings.get(name, name)
|
||||
|
||||
if internal_name in _tle_cache:
|
||||
_tle_cache[internal_name] = (name, line1, line2)
|
||||
if internal_name in current:
|
||||
new_entries[internal_name] = (name, line1, line2)
|
||||
if internal_name not in updated:
|
||||
updated.append(internal_name)
|
||||
|
||||
@@ -843,39 +871,12 @@ def refresh_tle_data() -> list:
|
||||
logger.warning(f"Error fetching TLE group {group}: {e}")
|
||||
continue
|
||||
|
||||
if updated:
|
||||
_persist_tle_cache()
|
||||
if new_entries:
|
||||
tle_store.update(new_entries)
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
def _persist_tle_cache() -> None:
|
||||
"""Write updated TLE data back to data/satellites.py so restarts don't reload stale values."""
|
||||
import os
|
||||
|
||||
satellites_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "satellites.py")
|
||||
try:
|
||||
lines = [
|
||||
"# TLE data for satellite tracking (updated periodically)\n",
|
||||
'# To update: click "Update TLE" in satellite dashboard or SSTV mode\n',
|
||||
"# Data source: CelesTrak (celestrak.org)\n",
|
||||
"TLE_SATELLITES = {\n",
|
||||
]
|
||||
for key, val in _tle_cache.items():
|
||||
name, line1, line2 = val
|
||||
escaped_name = name.replace("'", "\\'")
|
||||
escaped_key = key.replace("'", "\\'")
|
||||
lines.append(f" '{escaped_key}': ('{escaped_name}',\n")
|
||||
lines.append(f" '{line1}',\n")
|
||||
lines.append(f" '{line2}'),\n")
|
||||
lines.append("}\n")
|
||||
with open(satellites_path, "w") as f:
|
||||
f.writelines(lines)
|
||||
logger.info(f"Persisted {len(_tle_cache)} TLE entries to data/satellites.py")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to persist TLE cache to disk: {e}")
|
||||
|
||||
|
||||
@satellite_bp.route("/update-tle", methods=["POST"])
|
||||
def update_tle():
|
||||
"""Update TLE data from CelesTrak (API endpoint)."""
|
||||
@@ -966,7 +967,6 @@ def list_tracked_satellites():
|
||||
@satellite_bp.route("/tracked", methods=["POST"])
|
||||
def add_tracked_satellites_endpoint():
|
||||
"""Add one or more tracked satellites."""
|
||||
global _tle_cache
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return api_error("No data provided", 400)
|
||||
@@ -975,6 +975,7 @@ def add_tracked_satellites_endpoint():
|
||||
sat_list = data if isinstance(data, list) else [data]
|
||||
|
||||
normalized: list[dict] = []
|
||||
new_tle_entries: dict = {}
|
||||
for sat in sat_list:
|
||||
norad_id = str(sat.get("norad_id", sat.get("norad", "")))
|
||||
name = sat.get("name", "")
|
||||
@@ -995,10 +996,13 @@ def add_tracked_satellites_endpoint():
|
||||
}
|
||||
)
|
||||
|
||||
# Also inject into TLE cache if we have TLE data
|
||||
# Also inject into TLE store if we have TLE data
|
||||
if tle1 and tle2:
|
||||
cache_key = name.replace(" ", "-").upper()
|
||||
_tle_cache[cache_key] = (name, tle1, tle2)
|
||||
new_tle_entries[cache_key] = (name, tle1, tle2)
|
||||
|
||||
if new_tle_entries:
|
||||
tle_store.update(new_tle_entries)
|
||||
|
||||
# Single inserts preserve previous behavior; list inserts use DB-level bulk path.
|
||||
if len(normalized) == 1:
|
||||
|
||||
+167
-165
@@ -11,6 +11,7 @@ import contextlib
|
||||
import os
|
||||
import platform
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -38,7 +39,7 @@ try:
|
||||
except ImportError:
|
||||
_requests = None # type: ignore[assignment]
|
||||
|
||||
system_bp = Blueprint('system', __name__, url_prefix='/system')
|
||||
system_bp = Blueprint("system", __name__, url_prefix="/system")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background metrics collector
|
||||
@@ -62,7 +63,7 @@ def _get_app_start_time() -> float:
|
||||
try:
|
||||
import app as app_module
|
||||
|
||||
_app_start_time = getattr(app_module, '_app_start_time', time.time())
|
||||
_app_start_time = getattr(app_module, "_app_start_time", time.time())
|
||||
except Exception:
|
||||
_app_start_time = time.time()
|
||||
return _app_start_time
|
||||
@@ -75,7 +76,7 @@ def _get_app_version() -> str:
|
||||
|
||||
return VERSION
|
||||
except Exception:
|
||||
return 'unknown'
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _format_uptime(seconds: float) -> str:
|
||||
@@ -85,11 +86,11 @@ def _format_uptime(seconds: float) -> str:
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
parts = []
|
||||
if days > 0:
|
||||
parts.append(f'{days}d')
|
||||
parts.append(f"{days}d")
|
||||
if hours > 0:
|
||||
parts.append(f'{hours}h')
|
||||
parts.append(f'{minutes}m')
|
||||
return ' '.join(parts)
|
||||
parts.append(f"{hours}h")
|
||||
parts.append(f"{minutes}m")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _collect_process_status() -> dict[str, bool]:
|
||||
@@ -110,15 +111,15 @@ def _collect_process_status() -> dict[str, bool]:
|
||||
return False
|
||||
|
||||
processes: dict[str, bool] = {
|
||||
'pager': _alive('current_process'),
|
||||
'sensor': _alive('sensor_process'),
|
||||
'adsb': _alive('adsb_process'),
|
||||
'ais': _alive('ais_process'),
|
||||
'acars': _alive('acars_process'),
|
||||
'vdl2': _alive('vdl2_process'),
|
||||
'aprs': _alive('aprs_process'),
|
||||
'dsc': _alive('dsc_process'),
|
||||
'morse': _alive('morse_process'),
|
||||
"pager": _alive("current_process"),
|
||||
"sensor": _alive("sensor_process"),
|
||||
"adsb": _alive("adsb_process"),
|
||||
"ais": _alive("ais_process"),
|
||||
"acars": _alive("acars_process"),
|
||||
"vdl2": _alive("vdl2_process"),
|
||||
"aprs": _alive("aprs_process"),
|
||||
"dsc": _alive("dsc_process"),
|
||||
"morse": _alive("morse_process"),
|
||||
}
|
||||
|
||||
# WiFi
|
||||
@@ -126,26 +127,26 @@ def _collect_process_status() -> dict[str, bool]:
|
||||
from app import _get_wifi_health
|
||||
|
||||
wifi_active, _, _ = _get_wifi_health()
|
||||
processes['wifi'] = wifi_active
|
||||
processes["wifi"] = wifi_active
|
||||
except Exception:
|
||||
processes['wifi'] = False
|
||||
processes["wifi"] = False
|
||||
|
||||
# Bluetooth
|
||||
try:
|
||||
from app import _get_bluetooth_health
|
||||
|
||||
bt_active, _ = _get_bluetooth_health()
|
||||
processes['bluetooth'] = bt_active
|
||||
processes["bluetooth"] = bt_active
|
||||
except Exception:
|
||||
processes['bluetooth'] = False
|
||||
processes["bluetooth"] = False
|
||||
|
||||
# SubGHz
|
||||
try:
|
||||
from app import _get_subghz_active
|
||||
|
||||
processes['subghz'] = _get_subghz_active()
|
||||
processes["subghz"] = _get_subghz_active()
|
||||
except Exception:
|
||||
processes['subghz'] = False
|
||||
processes["subghz"] = False
|
||||
|
||||
return processes
|
||||
except Exception:
|
||||
@@ -154,15 +155,17 @@ def _collect_process_status() -> dict[str, bool]:
|
||||
|
||||
def _collect_throttle_flags() -> str | None:
|
||||
"""Read Raspberry Pi throttle flags via vcgencmd (Linux/Pi only)."""
|
||||
if shutil.which("vcgencmd") is None:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['vcgencmd', 'get_throttled'],
|
||||
["vcgencmd", "get_throttled"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
if result.returncode == 0 and 'throttled=' in result.stdout:
|
||||
return result.stdout.strip().split('=', 1)[1]
|
||||
if result.returncode == 0 and "throttled=" in result.stdout:
|
||||
return result.stdout.strip().split("=", 1)[1]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -171,11 +174,11 @@ def _collect_throttle_flags() -> str | None:
|
||||
def _collect_power_draw() -> float | None:
|
||||
"""Read power draw in watts from sysfs (Linux only)."""
|
||||
try:
|
||||
power_supply = Path('/sys/class/power_supply')
|
||||
power_supply = Path("/sys/class/power_supply")
|
||||
if not power_supply.exists():
|
||||
return None
|
||||
for supply_dir in power_supply.iterdir():
|
||||
power_file = supply_dir / 'power_now'
|
||||
power_file = supply_dir / "power_now"
|
||||
if power_file.exists():
|
||||
val = int(power_file.read_text().strip())
|
||||
return round(val / 1_000_000, 2) # microwatts to watts
|
||||
@@ -191,17 +194,17 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
uptime_seconds = round(now - start, 2)
|
||||
|
||||
metrics: dict[str, Any] = {
|
||||
'type': 'system_metrics',
|
||||
'timestamp': now,
|
||||
'system': {
|
||||
'hostname': socket.gethostname(),
|
||||
'platform': platform.platform(),
|
||||
'python': platform.python_version(),
|
||||
'version': _get_app_version(),
|
||||
'uptime_seconds': uptime_seconds,
|
||||
'uptime_human': _format_uptime(uptime_seconds),
|
||||
"type": "system_metrics",
|
||||
"timestamp": now,
|
||||
"system": {
|
||||
"hostname": socket.gethostname(),
|
||||
"platform": platform.platform(),
|
||||
"python": platform.python_version(),
|
||||
"version": _get_app_version(),
|
||||
"uptime_seconds": uptime_seconds,
|
||||
"uptime_human": _format_uptime(uptime_seconds),
|
||||
},
|
||||
'processes': _collect_process_status(),
|
||||
"processes": _collect_process_status(),
|
||||
}
|
||||
|
||||
if _HAS_PSUTIL:
|
||||
@@ -222,61 +225,61 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
freq = psutil.cpu_freq()
|
||||
if freq:
|
||||
freq_data = {
|
||||
'current': round(freq.current, 0),
|
||||
'min': round(freq.min, 0),
|
||||
'max': round(freq.max, 0),
|
||||
"current": round(freq.current, 0),
|
||||
"min": round(freq.min, 0),
|
||||
"max": round(freq.max, 0),
|
||||
}
|
||||
|
||||
metrics['cpu'] = {
|
||||
'percent': cpu_percent,
|
||||
'count': cpu_count,
|
||||
'load_1': round(load_1, 2),
|
||||
'load_5': round(load_5, 2),
|
||||
'load_15': round(load_15, 2),
|
||||
'per_core': per_core,
|
||||
'freq': freq_data,
|
||||
metrics["cpu"] = {
|
||||
"percent": cpu_percent,
|
||||
"count": cpu_count,
|
||||
"load_1": round(load_1, 2),
|
||||
"load_5": round(load_5, 2),
|
||||
"load_15": round(load_15, 2),
|
||||
"per_core": per_core,
|
||||
"freq": freq_data,
|
||||
}
|
||||
|
||||
# Memory
|
||||
mem = psutil.virtual_memory()
|
||||
metrics['memory'] = {
|
||||
'total': mem.total,
|
||||
'used': mem.used,
|
||||
'available': mem.available,
|
||||
'percent': mem.percent,
|
||||
metrics["memory"] = {
|
||||
"total": mem.total,
|
||||
"used": mem.used,
|
||||
"available": mem.available,
|
||||
"percent": mem.percent,
|
||||
}
|
||||
|
||||
swap = psutil.swap_memory()
|
||||
metrics['swap'] = {
|
||||
'total': swap.total,
|
||||
'used': swap.used,
|
||||
'percent': swap.percent,
|
||||
metrics["swap"] = {
|
||||
"total": swap.total,
|
||||
"used": swap.used,
|
||||
"percent": swap.percent,
|
||||
}
|
||||
|
||||
# Disk — usage + I/O counters
|
||||
try:
|
||||
disk = psutil.disk_usage('/')
|
||||
metrics['disk'] = {
|
||||
'total': disk.total,
|
||||
'used': disk.used,
|
||||
'free': disk.free,
|
||||
'percent': disk.percent,
|
||||
'path': '/',
|
||||
disk = psutil.disk_usage("/")
|
||||
metrics["disk"] = {
|
||||
"total": disk.total,
|
||||
"used": disk.used,
|
||||
"free": disk.free,
|
||||
"percent": disk.percent,
|
||||
"path": "/",
|
||||
}
|
||||
except Exception:
|
||||
metrics['disk'] = None
|
||||
metrics["disk"] = None
|
||||
|
||||
disk_io = None
|
||||
with contextlib.suppress(Exception):
|
||||
dio = psutil.disk_io_counters()
|
||||
if dio:
|
||||
disk_io = {
|
||||
'read_bytes': dio.read_bytes,
|
||||
'write_bytes': dio.write_bytes,
|
||||
'read_count': dio.read_count,
|
||||
'write_count': dio.write_count,
|
||||
"read_bytes": dio.read_bytes,
|
||||
"write_bytes": dio.write_bytes,
|
||||
"read_count": dio.read_count,
|
||||
"write_count": dio.write_count,
|
||||
}
|
||||
metrics['disk_io'] = disk_io
|
||||
metrics["disk_io"] = disk_io
|
||||
|
||||
# Temperatures
|
||||
try:
|
||||
@@ -286,18 +289,18 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
for chip, entries in temps.items():
|
||||
temp_data[chip] = [
|
||||
{
|
||||
'label': e.label or chip,
|
||||
'current': e.current,
|
||||
'high': e.high,
|
||||
'critical': e.critical,
|
||||
"label": e.label or chip,
|
||||
"current": e.current,
|
||||
"high": e.high,
|
||||
"critical": e.critical,
|
||||
}
|
||||
for e in entries
|
||||
]
|
||||
metrics['temperatures'] = temp_data
|
||||
metrics["temperatures"] = temp_data
|
||||
else:
|
||||
metrics['temperatures'] = None
|
||||
metrics["temperatures"] = None
|
||||
except (AttributeError, Exception):
|
||||
metrics['temperatures'] = None
|
||||
metrics["temperatures"] = None
|
||||
|
||||
# Fans
|
||||
fans_data = None
|
||||
@@ -306,11 +309,8 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
if fans:
|
||||
fans_data = {}
|
||||
for chip, entries in fans.items():
|
||||
fans_data[chip] = [
|
||||
{'label': e.label or chip, 'current': e.current}
|
||||
for e in entries
|
||||
]
|
||||
metrics['fans'] = fans_data
|
||||
fans_data[chip] = [{"label": e.label or chip, "current": e.current} for e in entries]
|
||||
metrics["fans"] = fans_data
|
||||
|
||||
# Battery
|
||||
battery_data = None
|
||||
@@ -318,11 +318,11 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
bat = psutil.sensors_battery()
|
||||
if bat:
|
||||
battery_data = {
|
||||
'percent': bat.percent,
|
||||
'plugged': bat.power_plugged,
|
||||
'secs_left': bat.secsleft if bat.secsleft != psutil.POWER_TIME_UNLIMITED else None,
|
||||
"percent": bat.percent,
|
||||
"plugged": bat.power_plugged,
|
||||
"secs_left": bat.secsleft if bat.secsleft != psutil.POWER_TIME_UNLIMITED else None,
|
||||
}
|
||||
metrics['battery'] = battery_data
|
||||
metrics["battery"] = battery_data
|
||||
|
||||
# Network interfaces
|
||||
net_ifaces: list[dict[str, Any]] = []
|
||||
@@ -330,25 +330,25 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
addrs = psutil.net_if_addrs()
|
||||
stats = psutil.net_if_stats()
|
||||
for iface_name in sorted(addrs.keys()):
|
||||
if iface_name == 'lo':
|
||||
if iface_name == "lo":
|
||||
continue
|
||||
iface_info: dict[str, Any] = {'name': iface_name}
|
||||
iface_info: dict[str, Any] = {"name": iface_name}
|
||||
# Get addresses
|
||||
for addr in addrs[iface_name]:
|
||||
if addr.family == socket.AF_INET:
|
||||
iface_info['ipv4'] = addr.address
|
||||
iface_info["ipv4"] = addr.address
|
||||
elif addr.family == socket.AF_INET6:
|
||||
iface_info.setdefault('ipv6', addr.address)
|
||||
iface_info.setdefault("ipv6", addr.address)
|
||||
elif addr.family == psutil.AF_LINK:
|
||||
iface_info['mac'] = addr.address
|
||||
iface_info["mac"] = addr.address
|
||||
# Get stats
|
||||
if iface_name in stats:
|
||||
st = stats[iface_name]
|
||||
iface_info['is_up'] = st.isup
|
||||
iface_info['speed'] = st.speed # Mbps
|
||||
iface_info['mtu'] = st.mtu
|
||||
iface_info["is_up"] = st.isup
|
||||
iface_info["speed"] = st.speed # Mbps
|
||||
iface_info["mtu"] = st.mtu
|
||||
net_ifaces.append(iface_info)
|
||||
metrics['network'] = {'interfaces': net_ifaces}
|
||||
metrics["network"] = {"interfaces": net_ifaces}
|
||||
|
||||
# Network I/O counters (raw — JS computes deltas)
|
||||
net_io = None
|
||||
@@ -357,43 +357,43 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
if counters:
|
||||
net_io = {}
|
||||
for nic, c in counters.items():
|
||||
if nic == 'lo':
|
||||
if nic == "lo":
|
||||
continue
|
||||
net_io[nic] = {
|
||||
'bytes_sent': c.bytes_sent,
|
||||
'bytes_recv': c.bytes_recv,
|
||||
"bytes_sent": c.bytes_sent,
|
||||
"bytes_recv": c.bytes_recv,
|
||||
}
|
||||
metrics['network']['io'] = net_io
|
||||
metrics["network"]["io"] = net_io
|
||||
|
||||
# Connection count
|
||||
conn_count = 0
|
||||
with contextlib.suppress(Exception):
|
||||
conn_count = len(psutil.net_connections())
|
||||
metrics['network']['connections'] = conn_count
|
||||
metrics["network"]["connections"] = conn_count
|
||||
|
||||
# Boot time
|
||||
boot_ts = None
|
||||
with contextlib.suppress(Exception):
|
||||
boot_ts = psutil.boot_time()
|
||||
metrics['boot_time'] = boot_ts
|
||||
metrics["boot_time"] = boot_ts
|
||||
|
||||
# Power / throttle (Pi-specific)
|
||||
metrics['power'] = {
|
||||
'throttled': _collect_throttle_flags(),
|
||||
'draw_watts': _collect_power_draw(),
|
||||
metrics["power"] = {
|
||||
"throttled": _collect_throttle_flags(),
|
||||
"draw_watts": _collect_power_draw(),
|
||||
}
|
||||
else:
|
||||
metrics['cpu'] = None
|
||||
metrics['memory'] = None
|
||||
metrics['swap'] = None
|
||||
metrics['disk'] = None
|
||||
metrics['disk_io'] = None
|
||||
metrics['temperatures'] = None
|
||||
metrics['fans'] = None
|
||||
metrics['battery'] = None
|
||||
metrics['network'] = None
|
||||
metrics['boot_time'] = None
|
||||
metrics['power'] = None
|
||||
metrics["cpu"] = None
|
||||
metrics["memory"] = None
|
||||
metrics["swap"] = None
|
||||
metrics["disk"] = None
|
||||
metrics["disk_io"] = None
|
||||
metrics["temperatures"] = None
|
||||
metrics["fans"] = None
|
||||
metrics["battery"] = None
|
||||
metrics["network"] = None
|
||||
metrics["boot_time"] = None
|
||||
metrics["power"] = None
|
||||
|
||||
return metrics
|
||||
|
||||
@@ -416,7 +416,7 @@ def _collector_loop() -> None:
|
||||
_metrics_queue.get_nowait()
|
||||
_metrics_queue.put_nowait(metrics)
|
||||
except Exception as exc:
|
||||
logger.debug('system metrics collection error: %s', exc)
|
||||
logger.debug("system metrics collection error: %s", exc)
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
@@ -428,15 +428,15 @@ def _ensure_collector() -> None:
|
||||
with _collector_lock:
|
||||
if _collector_started:
|
||||
return
|
||||
t = threading.Thread(target=_collector_loop, daemon=True, name='system-metrics-collector')
|
||||
t = threading.Thread(target=_collector_loop, daemon=True, name="system-metrics-collector")
|
||||
t.start()
|
||||
_collector_started = True
|
||||
logger.info('System metrics collector started')
|
||||
logger.info("System metrics collector started")
|
||||
|
||||
|
||||
def _get_observer_location() -> dict[str, Any]:
|
||||
"""Get observer location from GPS state or config defaults."""
|
||||
lat, lon, source = None, None, 'none'
|
||||
lat, lon, source = None, None, "none"
|
||||
gps_meta: dict[str, Any] = {}
|
||||
|
||||
# Try GPS via utils.gps
|
||||
@@ -445,13 +445,13 @@ def _get_observer_location() -> dict[str, Any]:
|
||||
|
||||
pos = get_current_position()
|
||||
if pos and pos.fix_quality >= 2:
|
||||
lat, lon, source = pos.latitude, pos.longitude, 'gps'
|
||||
gps_meta['fix_quality'] = pos.fix_quality
|
||||
gps_meta['satellites'] = pos.satellites
|
||||
lat, lon, source = pos.latitude, pos.longitude, "gps"
|
||||
gps_meta["fix_quality"] = pos.fix_quality
|
||||
gps_meta["satellites"] = pos.satellites
|
||||
if pos.epx is not None and pos.epy is not None:
|
||||
gps_meta['accuracy'] = round(max(pos.epx, pos.epy), 1)
|
||||
gps_meta["accuracy"] = round(max(pos.epx, pos.epy), 1)
|
||||
if pos.altitude is not None:
|
||||
gps_meta['altitude'] = round(pos.altitude, 1)
|
||||
gps_meta["altitude"] = round(pos.altitude, 1)
|
||||
|
||||
# Fall back to config env vars
|
||||
if lat is None:
|
||||
@@ -459,7 +459,7 @@ def _get_observer_location() -> dict[str, Any]:
|
||||
from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||
|
||||
if DEFAULT_LATITUDE != 0.0 or DEFAULT_LONGITUDE != 0.0:
|
||||
lat, lon, source = DEFAULT_LATITUDE, DEFAULT_LONGITUDE, 'config'
|
||||
lat, lon, source = DEFAULT_LATITUDE, DEFAULT_LONGITUDE, "config"
|
||||
|
||||
# Fall back to hardcoded constants (London)
|
||||
if lat is None:
|
||||
@@ -467,11 +467,11 @@ def _get_observer_location() -> dict[str, Any]:
|
||||
from utils.constants import DEFAULT_LATITUDE as CONST_LAT
|
||||
from utils.constants import DEFAULT_LONGITUDE as CONST_LON
|
||||
|
||||
lat, lon, source = CONST_LAT, CONST_LON, 'default'
|
||||
lat, lon, source = CONST_LAT, CONST_LON, "default"
|
||||
|
||||
result: dict[str, Any] = {'lat': lat, 'lon': lon, 'source': source}
|
||||
result: dict[str, Any] = {"lat": lat, "lon": lon, "source": source}
|
||||
if gps_meta:
|
||||
result['gps'] = gps_meta
|
||||
result["gps"] = gps_meta
|
||||
return result
|
||||
|
||||
|
||||
@@ -480,14 +480,14 @@ def _get_observer_location() -> dict[str, Any]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@system_bp.route('/metrics')
|
||||
@system_bp.route("/metrics")
|
||||
def get_metrics() -> Response:
|
||||
"""REST snapshot of current system metrics."""
|
||||
_ensure_collector()
|
||||
return jsonify(_collect_metrics())
|
||||
|
||||
|
||||
@system_bp.route('/stream')
|
||||
@system_bp.route("/stream")
|
||||
def stream_system() -> Response:
|
||||
"""SSE stream for real-time system metrics."""
|
||||
_ensure_collector()
|
||||
@@ -495,18 +495,18 @@ def stream_system() -> Response:
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_metrics_queue,
|
||||
channel_key='system',
|
||||
channel_key="system",
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
mimetype="text/event-stream",
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
response.headers["X-Accel-Buffering"] = "no"
|
||||
return response
|
||||
|
||||
|
||||
@system_bp.route('/sdr_devices')
|
||||
@system_bp.route("/sdr_devices")
|
||||
def get_sdr_devices() -> Response:
|
||||
"""Enumerate all connected SDR devices (on-demand, not every tick)."""
|
||||
try:
|
||||
@@ -515,26 +515,28 @@ def get_sdr_devices() -> Response:
|
||||
devices = detect_all_devices()
|
||||
result = []
|
||||
for d in devices:
|
||||
result.append({
|
||||
'type': d.sdr_type.value if hasattr(d.sdr_type, 'value') else str(d.sdr_type),
|
||||
'index': d.index,
|
||||
'name': d.name,
|
||||
'serial': d.serial or '',
|
||||
'driver': d.driver or '',
|
||||
})
|
||||
return jsonify({'devices': result})
|
||||
result.append(
|
||||
{
|
||||
"type": d.sdr_type.value if hasattr(d.sdr_type, "value") else str(d.sdr_type),
|
||||
"index": d.index,
|
||||
"name": d.name,
|
||||
"serial": d.serial or "",
|
||||
"driver": d.driver or "",
|
||||
}
|
||||
)
|
||||
return jsonify({"devices": result})
|
||||
except Exception as exc:
|
||||
logger.warning('SDR device detection failed: %s', exc)
|
||||
return jsonify({'devices': [], 'error': str(exc)})
|
||||
logger.warning("SDR device detection failed: %s", exc)
|
||||
return jsonify({"devices": [], "error": str(exc)})
|
||||
|
||||
|
||||
@system_bp.route('/location')
|
||||
@system_bp.route("/location")
|
||||
def get_location() -> Response:
|
||||
"""Return observer location from GPS or config."""
|
||||
return jsonify(_get_observer_location())
|
||||
|
||||
|
||||
@system_bp.route('/weather')
|
||||
@system_bp.route("/weather")
|
||||
def get_weather() -> Response:
|
||||
"""Proxy weather from wttr.in, cached for 10 minutes."""
|
||||
global _weather_cache, _weather_cache_time
|
||||
@@ -543,42 +545,42 @@ def get_weather() -> Response:
|
||||
if _weather_cache and (now - _weather_cache_time) < _WEATHER_CACHE_TTL:
|
||||
return jsonify(_weather_cache)
|
||||
|
||||
lat = request.args.get('lat', type=float)
|
||||
lon = request.args.get('lon', type=float)
|
||||
lat = request.args.get("lat", type=float)
|
||||
lon = request.args.get("lon", type=float)
|
||||
if lat is None or lon is None:
|
||||
loc = _get_observer_location()
|
||||
lat, lon = loc.get('lat'), loc.get('lon')
|
||||
lat, lon = loc.get("lat"), loc.get("lon")
|
||||
|
||||
if lat is None or lon is None:
|
||||
return api_error('No location available')
|
||||
return api_error("No location available")
|
||||
|
||||
if _requests is None:
|
||||
return api_error('requests library not available')
|
||||
return api_error("requests library not available")
|
||||
|
||||
try:
|
||||
resp = _requests.get(
|
||||
f'https://wttr.in/{lat},{lon}?format=j1',
|
||||
f"https://wttr.in/{lat},{lon}?format=j1",
|
||||
timeout=5,
|
||||
headers={'User-Agent': 'INTERCEPT-SystemHealth/1.0'},
|
||||
headers={"User-Agent": "INTERCEPT-SystemHealth/1.0"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
current = data.get('current_condition', [{}])[0]
|
||||
current = data.get("current_condition", [{}])[0]
|
||||
weather = {
|
||||
'temp_c': current.get('temp_C'),
|
||||
'temp_f': current.get('temp_F'),
|
||||
'condition': current.get('weatherDesc', [{}])[0].get('value', ''),
|
||||
'humidity': current.get('humidity'),
|
||||
'wind_mph': current.get('windspeedMiles'),
|
||||
'wind_dir': current.get('winddir16Point'),
|
||||
'feels_like_c': current.get('FeelsLikeC'),
|
||||
'visibility': current.get('visibility'),
|
||||
'pressure': current.get('pressure'),
|
||||
"temp_c": current.get("temp_C"),
|
||||
"temp_f": current.get("temp_F"),
|
||||
"condition": current.get("weatherDesc", [{}])[0].get("value", ""),
|
||||
"humidity": current.get("humidity"),
|
||||
"wind_mph": current.get("windspeedMiles"),
|
||||
"wind_dir": current.get("winddir16Point"),
|
||||
"feels_like_c": current.get("FeelsLikeC"),
|
||||
"visibility": current.get("visibility"),
|
||||
"pressure": current.get("pressure"),
|
||||
}
|
||||
_weather_cache = weather
|
||||
_weather_cache_time = now
|
||||
return jsonify(weather)
|
||||
except Exception as exc:
|
||||
logger.debug('Weather fetch failed: %s', exc)
|
||||
logger.debug("Weather fetch failed: %s", exc)
|
||||
return api_error(str(exc))
|
||||
|
||||
@@ -492,14 +492,24 @@ raise SystemExit(0 if sys.version_info >= (3,9) else 1)
|
||||
PY
|
||||
ok "Python version OK (>= 3.9)"
|
||||
|
||||
# meshcore (MeshCore mesh networking) requires Python 3.10+
|
||||
if python3 - <<'PY'
|
||||
import sys
|
||||
raise SystemExit(0 if sys.version_info < (3,10) else 1)
|
||||
PY
|
||||
then
|
||||
warn "Python 3.9 detected: MeshCore support requires Python 3.10+ and will be unavailable."
|
||||
fi
|
||||
|
||||
# Python 3.13+ warning: some packages (gevent, numpy, scipy) may not have
|
||||
# pre-built wheels yet and will be skipped to avoid hanging on compilation.
|
||||
# pre-built wheels yet; prefer wheels over source builds to avoid hanging
|
||||
# on compilation (--prefer-binary is added to PIP_OPTS in install_python_deps).
|
||||
if python3 - <<'PY'
|
||||
import sys
|
||||
raise SystemExit(0 if sys.version_info >= (3,13) else 1)
|
||||
PY
|
||||
then
|
||||
warn "Python 3.13+ detected: optional packages without pre-built wheels will be skipped (--prefer-binary)."
|
||||
warn "Python 3.13+ detected: preferring pre-built wheels over source builds (--prefer-binary)."
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -537,6 +547,10 @@ install_python_deps() {
|
||||
# --no-cache-dir avoids pip hanging on a corrupt/stale HTTP cache (cachecontrol .pyc issue)
|
||||
# --timeout prevents pip from hanging indefinitely on slow/unresponsive PyPI connections
|
||||
local PIP_OPTS="--no-cache-dir --timeout 120"
|
||||
# Python 3.13+: prefer wheels over source builds (see check_python_version warning)
|
||||
if "$PY" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3,13) else 1)'; then
|
||||
PIP_OPTS="$PIP_OPTS --prefer-binary"
|
||||
fi
|
||||
|
||||
if ! $PIP install $PIP_OPTS --upgrade pip setuptools wheel; then
|
||||
warn "pip/setuptools/wheel upgrade failed - continuing with existing versions"
|
||||
@@ -562,27 +576,27 @@ install_python_deps() {
|
||||
ok "Core Python packages installed"
|
||||
|
||||
info "Installing optional packages..."
|
||||
# Pure-Python packages: install without --only-binary so they always succeed regardless of platform
|
||||
for pkg in "flask-sock" "simple-websocket>=0.5.1" "websocket-client>=1.6.0" \
|
||||
"skyfield>=1.45" "bleak>=0.21.0" "meshtastic>=2.0.0" \
|
||||
"qrcode[pil]>=7.4" "gunicorn>=21.2.0" "psutil>=5.9.0"; do
|
||||
pkg_name="${pkg%%[><=]*}"
|
||||
# Optional package specs come from requirements.txt (single source of truth).
|
||||
# Packages already installed by the core step above are skipped here.
|
||||
local core_pkgs="flask flask-wtf flask-compress flask-limiter requests Werkzeug pyserial"
|
||||
# Heavy compiled packages: install with --only-binary :all: to skip slow
|
||||
# source compilation on RPi. Everything else installs without it (note:
|
||||
# transitive deps may still be compiled, e.g. meshcore -> pycryptodome;
|
||||
# failures are tolerated since these features are optional).
|
||||
local binary_only_pkgs="numpy scipy Pillow psycopg2-binary scapy cryptography gevent"
|
||||
local pkg pkg_name extra_opts
|
||||
while IFS= read -r pkg; do
|
||||
pkg="${pkg%"${pkg##*[![:space:]]}"}" # trim trailing whitespace
|
||||
[[ -z "$pkg" || "$pkg" == \#* ]] && continue
|
||||
pkg_name="${pkg%%[><=[]*}"
|
||||
case " $core_pkgs " in *" $pkg_name "*) continue ;; esac
|
||||
extra_opts=""
|
||||
case " $binary_only_pkgs " in *" $pkg_name "*) extra_opts="--only-binary :all:" ;; esac
|
||||
info " Installing ${pkg_name}..."
|
||||
if ! $PIP install $PIP_OPTS "$pkg"; then
|
||||
if ! $PIP install $PIP_OPTS $extra_opts "$pkg"; then
|
||||
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
|
||||
fi
|
||||
done
|
||||
# Compiled packages: use --only-binary :all: to skip slow source compilation on RPi
|
||||
for pkg in "numpy>=1.24.0" "scipy>=1.10.0" "Pillow>=9.0.0" \
|
||||
"psycopg2-binary>=2.9.9" "scapy>=2.4.5" "cryptography>=41.0.0" \
|
||||
"gevent>=23.9.0"; do
|
||||
pkg_name="${pkg%%[><=]*}"
|
||||
info " Installing ${pkg_name}..."
|
||||
# --only-binary :all: prevents source compilation hangs for heavy packages
|
||||
if ! $PIP install $PIP_OPTS --only-binary :all: "$pkg"; then
|
||||
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
|
||||
fi
|
||||
done
|
||||
done < requirements.txt
|
||||
ok "Optional packages processed"
|
||||
echo
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/* ============================================================
|
||||
Signal View Wrap — flex container for split-panel layouts
|
||||
============================================================ */
|
||||
#signalViewWrap {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Feed column — wraps feed header + #output, fills remaining space */
|
||||
.pdir-feed-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Feed header strip — shown in directory mode above the message list */
|
||||
.pdir-feed-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pdir-clear-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
.pdir-clear-btn:hover { color: var(--text-dim); }
|
||||
|
||||
/* ---- Directory panel (left side of split) ---- */
|
||||
.pdir-panel {
|
||||
display: flex;
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--border-color);
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.pdir-header {
|
||||
padding: 6px 10px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-card);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pdir-entries {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ---- Individual address entry ---- */
|
||||
.pdir-entry {
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid rgba(var(--accent-cyan-rgb), 0.04);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
.pdir-entry:hover { background: var(--bg-tertiary); }
|
||||
.pdir-entry--active {
|
||||
background: rgba(var(--accent-cyan-rgb), 0.06);
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.pdir-entry-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.pdir-proto {
|
||||
font-size: 8px;
|
||||
padding: 1px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: var(--font-bold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pdir-proto--p { background: rgba(var(--accent-cyan-rgb), 0.15); color: var(--accent-cyan); }
|
||||
.pdir-proto--f { background: rgba(var(--accent-purple-rgb), 0.15); color: var(--accent-purple); }
|
||||
|
||||
.pdir-addr {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pdir-new-dot {
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
.pdir-new-dot--active {
|
||||
animation: pdir-dot-fade 3s ease-out forwards;
|
||||
}
|
||||
@keyframes pdir-dot-fade {
|
||||
0% { opacity: 1; }
|
||||
85% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.pdir-count { font-size: 9px; color: var(--text-muted); flex-shrink: 0; }
|
||||
|
||||
.pdir-bar-wrap { height: 2px; background: var(--bg-tertiary); border-radius: 1px; margin-bottom: 2px; }
|
||||
.pdir-bar { height: 2px; background: var(--accent-cyan); border-radius: 1px; transition: width var(--transition-slow); }
|
||||
.pdir-bar--flex { background: var(--accent-purple); }
|
||||
|
||||
.pdir-age { font-size: 8px; color: var(--text-muted); }
|
||||
|
||||
/* ---- Highlight applied to signal-cards in #output ---- */
|
||||
.signal-card.pdir-hl {
|
||||
border-left: 2px solid var(--accent-cyan) !important;
|
||||
background: rgba(var(--accent-cyan-rgb), 0.04) !important;
|
||||
}
|
||||
|
||||
/* ---- View toggle button group (inside .stats) ---- */
|
||||
.stats .view-toggle-group { display: none; }
|
||||
.stats.active .view-toggle-group { display: flex; }
|
||||
|
||||
.view-toggle-group {
|
||||
gap: 2px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.view-toggle-btn {
|
||||
padding: 2px 8px;
|
||||
font-size: 9px;
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
.view-toggle-btn:hover { color: var(--text-dim); }
|
||||
.view-toggle-btn--active {
|
||||
background: var(--accent-cyan-dim);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/* ============================================================
|
||||
Sensor Dashboard View
|
||||
============================================================ */
|
||||
.sdb-view {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.sdb-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* ---- Station card ---- */
|
||||
.sdb-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 10px;
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sdb-card--new {
|
||||
border-color: rgba(var(--accent-green-rgb), 0.3);
|
||||
animation: sdb-slide-in 0.4s ease-out;
|
||||
}
|
||||
@keyframes sdb-slide-in {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.sdb-card--flash-blue {
|
||||
animation: sdb-flash-blue 0.8s ease-out;
|
||||
}
|
||||
@keyframes sdb-flash-blue {
|
||||
0% { background: rgba(var(--accent-cyan-rgb), 0.10); border-color: rgba(var(--accent-cyan-rgb), 0.30); }
|
||||
100% { background: var(--bg-card); border-color: var(--border-color); }
|
||||
}
|
||||
|
||||
.sdb-card--flash-purple {
|
||||
animation: sdb-flash-purple 0.8s ease-out;
|
||||
}
|
||||
@keyframes sdb-flash-purple {
|
||||
0% { background: rgba(var(--accent-purple-rgb), 0.10); border-color: rgba(var(--accent-purple-rgb), 0.30); }
|
||||
100% { background: var(--bg-card); border-color: var(--border-color); }
|
||||
}
|
||||
|
||||
/* ---- Card header ---- */
|
||||
.sdb-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sdb-name {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent-cyan);
|
||||
font-weight: var(--font-semibold);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 120px;
|
||||
}
|
||||
.sdb-id { font-size: 8px; color: var(--text-muted); margin-top: 1px; }
|
||||
.sdb-age { font-size: 8px; color: var(--text-muted); white-space: nowrap; }
|
||||
.sdb-age--fresh { color: var(--accent-green); }
|
||||
|
||||
/* ---- Readings grid ---- */
|
||||
.sdb-readings {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
min-height: 36px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.sdb-reading { text-align: center; min-width: 34px; }
|
||||
.sdb-reading-val { font-size: 15px; font-weight: var(--font-bold); line-height: 1; }
|
||||
.sdb-reading-unit { font-size: 8px; color: var(--text-muted); }
|
||||
.sdb-reading-label { font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 1px; }
|
||||
.sdb-no-readings { font-size: 9px; color: var(--text-muted); align-self: center; }
|
||||
|
||||
/* ---- State-only device ---- */
|
||||
.sdb-state { display: flex; align-items: center; gap: 6px; min-height: 36px; }
|
||||
.sdb-state-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.sdb-state-dot--on { background: var(--accent-green); box-shadow: 0 0 5px var(--accent-green); }
|
||||
.sdb-state-dot--off { background: var(--text-muted); }
|
||||
.sdb-state-label { font-size: 9px; color: var(--text-secondary); }
|
||||
|
||||
/* ---- Sparkline ---- */
|
||||
.sdb-spark { margin-bottom: 6px; }
|
||||
.sdb-spark svg { width: 100%; height: 22px; display: block; }
|
||||
.sdb-spark-placeholder {
|
||||
height: 22px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
font-size: 8px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ---- Card footer ---- */
|
||||
.sdb-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 8px;
|
||||
}
|
||||
.sdb-bat--ok { color: var(--accent-green); }
|
||||
.sdb-bat--low { color: var(--accent-red); }
|
||||
.sdb-snr { color: var(--text-muted); }
|
||||
.sdb-freq {
|
||||
padding: 1px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -106,6 +106,36 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Graticule toggle button ---
|
||||
Rendered as a Leaflet control (bottomleft by default). */
|
||||
|
||||
.map-graticule-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: rgba(7, 9, 14, 0.82);
|
||||
border: 1px solid rgba(var(--accent-cyan-rgb, 74 163 255), 0.2);
|
||||
border-radius: 4px;
|
||||
color: rgba(var(--accent-cyan-rgb, 74 163 255), 0.45);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.map-graticule-btn:hover {
|
||||
background: rgba(7, 9, 14, 0.95);
|
||||
border-color: rgba(var(--accent-cyan-rgb, 74 163 255), 0.5);
|
||||
color: rgba(var(--accent-cyan-rgb, 74 163 255), 0.8);
|
||||
}
|
||||
|
||||
.map-graticule-btn.active {
|
||||
background: rgba(var(--accent-cyan-rgb, 74 163 255), 0.12);
|
||||
border-color: rgba(var(--accent-cyan-rgb, 74 163 255), 0.55);
|
||||
color: var(--accent-cyan, #4aa3ff);
|
||||
}
|
||||
|
||||
/* --- Dark glass popup ---
|
||||
Applied via MapUtils.glassPopupOptions() className. */
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
--accent-amber-dim: rgba(214, 168, 94, 0.18);
|
||||
--accent-yellow: #e1c26b;
|
||||
--accent-purple: #8f7bd6;
|
||||
--accent-purple-rgb: 143, 123, 214;
|
||||
--accent-green-rgb: 56, 193, 128;
|
||||
|
||||
/* Text hierarchy */
|
||||
--text-primary: #d7e0ee;
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
/* ACARS Sidebar Styles */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Main ACARS Sidebar (Collapsible) */
|
||||
.main-acars-sidebar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: var(--bg-panel);
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
.main-acars-collapse-btn {
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
background: rgba(0,0,0,0.4);
|
||||
border: none;
|
||||
border-right: 1px solid var(--border-color);
|
||||
color: var(--accent-cyan);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 6px 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.main-acars-collapse-btn:hover {
|
||||
background: rgba(var(--accent-cyan-rgb), 0.15);
|
||||
}
|
||||
.main-acars-collapse-label {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.main-acars-sidebar.collapsed .main-acars-collapse-label { display: block; }
|
||||
.main-acars-sidebar:not(.collapsed) .main-acars-collapse-label { display: none; }
|
||||
#mainAcarsCollapseIcon {
|
||||
font-size: 10px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.main-acars-sidebar.collapsed #mainAcarsCollapseIcon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.main-acars-content {
|
||||
width: 196px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: width 0.3s ease, opacity 0.2s ease;
|
||||
}
|
||||
.main-acars-sidebar.collapsed .main-acars-content {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.main-acars-messages {
|
||||
max-height: 350px;
|
||||
}
|
||||
.main-acars-msg {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
animation: fadeInMsg 0.3s ease;
|
||||
}
|
||||
.main-acars-msg:hover {
|
||||
background: rgba(var(--accent-cyan-rgb), 0.05);
|
||||
}
|
||||
@keyframes fadeInMsg {
|
||||
from { opacity: 0; transform: translateY(-3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ACARS Status Indicator */
|
||||
.acars-status-dot.listening {
|
||||
background: var(--accent-cyan) !important;
|
||||
animation: acars-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.acars-status-dot.receiving {
|
||||
background: var(--accent-green) !important;
|
||||
}
|
||||
.acars-status-dot.error {
|
||||
background: var(--accent-red) !important;
|
||||
}
|
||||
@keyframes acars-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(var(--accent-cyan-rgb), 0.7); }
|
||||
50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(var(--accent-cyan-rgb), 0.3); }
|
||||
}
|
||||
|
||||
/* ACARS Standalone Message Feed */
|
||||
.acars-message-feed {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
.acars-message-feed::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.acars-message-feed::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.acars-feed-card {
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.acars-feed-card:hover {
|
||||
background: rgba(var(--accent-cyan-rgb), 0.05);
|
||||
}
|
||||
|
||||
/* Clickable ACARS sidebar messages (linked to tracked aircraft) */
|
||||
.acars-message-item[style*="cursor: pointer"]:hover {
|
||||
background: rgba(var(--accent-cyan-rgb), 0.1);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/* VDL2 Mode Styles */
|
||||
|
||||
/* VDL2 Status Indicator */
|
||||
.vdl2-status-dot.listening {
|
||||
background: var(--accent-cyan) !important;
|
||||
animation: vdl2-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.vdl2-status-dot.receiving {
|
||||
background: var(--accent-green) !important;
|
||||
}
|
||||
.vdl2-status-dot.error {
|
||||
background: var(--accent-red) !important;
|
||||
}
|
||||
@keyframes vdl2-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(var(--accent-cyan-rgb), 0.7); }
|
||||
50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(var(--accent-cyan-rgb), 0.3); }
|
||||
}
|
||||
|
||||
/* VDL2 message animation */
|
||||
.vdl2-msg {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
animation: vdl2FadeIn 0.3s ease;
|
||||
}
|
||||
.vdl2-msg:hover {
|
||||
background: rgba(var(--accent-cyan-rgb), 0.05);
|
||||
}
|
||||
@keyframes vdl2FadeIn {
|
||||
from { opacity: 0; transform: translateY(-3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
const PagerDirectory = (function () {
|
||||
'use strict';
|
||||
|
||||
const STORAGE_KEY = 'pagerView';
|
||||
|
||||
// Map<address, { count, protocol, lastSeen }>
|
||||
const addresses = new Map();
|
||||
let highlighted = null;
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function esc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function formatAge(ts) {
|
||||
const s = Math.floor((Date.now() - ts) / 1000);
|
||||
if (s < 10) return 'just now';
|
||||
if (s < 60) return `${s}s ago`;
|
||||
return `${Math.floor(s / 60)}m ago`;
|
||||
}
|
||||
|
||||
// ---- Directory rendering ----
|
||||
|
||||
function renderDirectory() {
|
||||
const entriesEl = document.getElementById('pagerDirEntries');
|
||||
const countEl = document.getElementById('pagerDirCount');
|
||||
if (!entriesEl) return;
|
||||
|
||||
const sorted = [...addresses.entries()].sort((a, b) => b[1].count - a[1].count);
|
||||
const maxCount = sorted.length > 0 ? sorted[0][1].count : 1;
|
||||
|
||||
if (countEl) countEl.textContent = sorted.length;
|
||||
|
||||
sorted.forEach(([addr, data]) => {
|
||||
let el = entriesEl.querySelector(`[data-pdir-addr="${CSS.escape(addr)}"]`);
|
||||
const isActive = addr === highlighted;
|
||||
const pct = Math.round((data.count / maxCount) * 100);
|
||||
const isPocsag = data.protocol !== 'flex';
|
||||
const protoClass = isPocsag ? 'pdir-proto--p' : 'pdir-proto--f';
|
||||
const barClass = isPocsag ? '' : 'pdir-bar--flex';
|
||||
const html = `
|
||||
<div class="pdir-entry-top">
|
||||
<span class="pdir-proto ${protoClass}">${isPocsag ? 'P' : 'F'}</span>
|
||||
<span class="pdir-addr">${esc(addr)}</span>
|
||||
<span class="pdir-new-dot"></span>
|
||||
<span class="pdir-count">×${data.count}</span>
|
||||
</div>
|
||||
<div class="pdir-bar-wrap"><div class="pdir-bar ${barClass}" style="width:${pct}%"></div></div>
|
||||
<div class="pdir-age">${formatAge(data.lastSeen)}</div>`;
|
||||
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.className = 'pdir-entry';
|
||||
el.dataset.pdirAddr = addr;
|
||||
el.addEventListener('click', () => toggleHighlight(addr));
|
||||
entriesEl.appendChild(el);
|
||||
}
|
||||
el.classList.toggle('pdir-entry--active', isActive);
|
||||
el.innerHTML = html;
|
||||
});
|
||||
|
||||
// Re-order DOM to match sort
|
||||
sorted.forEach(([addr]) => {
|
||||
const el = entriesEl.querySelector(`[data-pdir-addr="${CSS.escape(addr)}"]`);
|
||||
if (el) entriesEl.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function flashNewDot(addr) {
|
||||
// Find the dot inside this entry after the current render frame
|
||||
setTimeout(() => {
|
||||
const entriesEl = document.getElementById('pagerDirEntries');
|
||||
const entry = entriesEl?.querySelector(`[data-pdir-addr="${CSS.escape(addr)}"]`);
|
||||
const dot = entry?.querySelector('.pdir-new-dot');
|
||||
if (!dot) return;
|
||||
dot.classList.remove('pdir-new-dot--active');
|
||||
void dot.offsetWidth; // force reflow to restart animation
|
||||
dot.classList.add('pdir-new-dot--active');
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// ---- Highlight ----
|
||||
|
||||
function toggleHighlight(addr) {
|
||||
if (highlighted === addr) clearHighlight();
|
||||
else highlight(addr);
|
||||
}
|
||||
|
||||
function highlight(addr) {
|
||||
highlighted = addr;
|
||||
renderDirectory();
|
||||
|
||||
const feedLabel = document.getElementById('pagerFeedLabel');
|
||||
const clearBtn = document.getElementById('pagerClearHighlight');
|
||||
if (feedLabel) feedLabel.textContent = `${addr} highlighted`;
|
||||
if (clearBtn) clearBtn.style.display = 'inline';
|
||||
|
||||
const output = document.getElementById('output');
|
||||
if (!output) return;
|
||||
|
||||
output.querySelectorAll('.signal-card').forEach(card => {
|
||||
card.classList.toggle('pdir-hl', card.dataset.address === addr);
|
||||
});
|
||||
|
||||
const first = output.querySelector(`.signal-card[data-address="${CSS.escape(addr)}"]`);
|
||||
if (first) first.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function clearHighlight() {
|
||||
highlighted = null;
|
||||
renderDirectory();
|
||||
|
||||
const feedLabel = document.getElementById('pagerFeedLabel');
|
||||
const clearBtn = document.getElementById('pagerClearHighlight');
|
||||
if (feedLabel) feedLabel.textContent = 'All messages';
|
||||
if (clearBtn) clearBtn.style.display = 'none';
|
||||
|
||||
document.getElementById('output')
|
||||
?.querySelectorAll('.pdir-hl')
|
||||
.forEach(c => c.classList.remove('pdir-hl'));
|
||||
}
|
||||
|
||||
// ---- Public: message hook ----
|
||||
|
||||
function addMessage(msg) {
|
||||
const addr = msg.address;
|
||||
if (!addr) return;
|
||||
const proto = (msg.protocol || '').includes('FLEX') ? 'flex' : 'pocsag';
|
||||
const entry = addresses.get(addr);
|
||||
if (entry) {
|
||||
entry.count++;
|
||||
entry.lastSeen = Date.now();
|
||||
entry.protocol = proto;
|
||||
} else {
|
||||
addresses.set(addr, { count: 1, protocol: proto, lastSeen: Date.now() });
|
||||
}
|
||||
renderDirectory();
|
||||
flashNewDot(addr);
|
||||
// Re-apply highlight class to the newly inserted card (caller inserts it after this hook)
|
||||
if (highlighted === addr) {
|
||||
setTimeout(() => {
|
||||
const output = document.getElementById('output');
|
||||
output?.querySelectorAll(`.signal-card[data-address="${CSS.escape(addr)}"]`)
|
||||
.forEach(c => c.classList.add('pdir-hl'));
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Show / hide / reset ----
|
||||
|
||||
function applyViewState(mode) {
|
||||
const dirPanel = document.getElementById('pagerDirectoryView');
|
||||
const feedHeader = document.getElementById('pagerFeedHeader');
|
||||
|
||||
if (mode === 'pager') {
|
||||
const saved = localStorage.getItem(STORAGE_KEY) || 'directory';
|
||||
const isDir = saved === 'directory';
|
||||
if (dirPanel) dirPanel.style.display = isDir ? 'flex' : 'none';
|
||||
if (feedHeader) feedHeader.style.display = isDir ? 'flex' : 'none';
|
||||
_updateToggle(isDir);
|
||||
renderDirectory();
|
||||
} else {
|
||||
if (dirPanel) dirPanel.style.display = 'none';
|
||||
if (feedHeader) feedHeader.style.display = 'none';
|
||||
clearHighlight();
|
||||
}
|
||||
}
|
||||
|
||||
function show() {
|
||||
localStorage.setItem(STORAGE_KEY, 'directory');
|
||||
applyViewState('pager');
|
||||
}
|
||||
|
||||
function hide() {
|
||||
localStorage.setItem(STORAGE_KEY, 'feed');
|
||||
applyViewState('pager');
|
||||
}
|
||||
|
||||
function _updateToggle(isDir) {
|
||||
document.getElementById('pagerToggleDir')?.classList.toggle('view-toggle-btn--active', isDir);
|
||||
document.getElementById('pagerToggleFeed')?.classList.toggle('view-toggle-btn--active', !isDir);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
addresses.clear();
|
||||
highlighted = null;
|
||||
const entriesEl = document.getElementById('pagerDirEntries');
|
||||
const countEl = document.getElementById('pagerDirCount');
|
||||
if (entriesEl) entriesEl.innerHTML = '';
|
||||
if (countEl) countEl.textContent = '0';
|
||||
clearHighlight();
|
||||
}
|
||||
|
||||
return { addMessage, highlight, clearHighlight, show, hide, reset, applyViewState };
|
||||
})();
|
||||
@@ -0,0 +1,201 @@
|
||||
const SensorDashboard = (function () {
|
||||
'use strict';
|
||||
|
||||
const STORAGE_KEY = 'sensorView';
|
||||
const MAX_SPARK_PTS = 30;
|
||||
|
||||
// Map<deviceKey, { card: HTMLElement, history: number[], primaryColor: string }>
|
||||
const devices = new Map();
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function esc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function formatAge(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const ts = typeof timestamp === 'string' ? new Date(timestamp).getTime() : Number(timestamp);
|
||||
const s = Math.floor((Date.now() - ts) / 1000);
|
||||
if (s < 10) return 'just now';
|
||||
if (s < 60) return `${s}s ago`;
|
||||
return `${Math.floor(s / 60)}m ago`;
|
||||
}
|
||||
|
||||
function isRecent(timestamp) {
|
||||
if (!timestamp) return false;
|
||||
const ts = typeof timestamp === 'string' ? new Date(timestamp).getTime() : Number(timestamp);
|
||||
return (Date.now() - ts) < 10000;
|
||||
}
|
||||
|
||||
// ---- Primary value for sparkline ----
|
||||
|
||||
function getPrimary(msg) {
|
||||
if (msg.temperature !== undefined)
|
||||
return { value: msg.temperature, color: '#f59e0b' };
|
||||
if (msg.pressure !== undefined)
|
||||
return { value: msg.pressure, color: '#a78bfa' };
|
||||
if (msg.wind_speed !== undefined)
|
||||
return { value: msg.wind_speed, color: '#4aa3ff' };
|
||||
return null;
|
||||
}
|
||||
|
||||
function getFlashClass(msg) {
|
||||
return msg.temperature !== undefined ? 'sdb-card--flash-blue' : 'sdb-card--flash-purple';
|
||||
}
|
||||
|
||||
// ---- HTML builders ----
|
||||
|
||||
function buildReadingsHTML(msg) {
|
||||
// State-only device (no continuous numeric field)
|
||||
if (msg.state !== undefined && msg.temperature === undefined
|
||||
&& msg.pressure === undefined && msg.wind_speed === undefined) {
|
||||
const raw = String(msg.state);
|
||||
const isOn = raw === '1' || raw === 'true' || raw === 'on' || raw === 'active';
|
||||
return `<div class="sdb-state">
|
||||
<span class="sdb-state-dot ${isOn ? 'sdb-state-dot--on' : 'sdb-state-dot--off'}"></span>
|
||||
<span class="sdb-state-label">${esc(raw.toUpperCase())}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
if (msg.temperature !== undefined)
|
||||
parts.push({ val: msg.temperature, unit: `°${msg.temperature_unit || 'C'}`, label: 'Temp', color: '#f59e0b' });
|
||||
if (msg.humidity !== undefined)
|
||||
parts.push({ val: msg.humidity, unit: '%', label: 'Humid', color: '#38bdf8' });
|
||||
if (msg.pressure !== undefined)
|
||||
parts.push({ val: msg.pressure, unit: msg.pressure_unit || 'hPa', label: 'Press', color: '#a78bfa' });
|
||||
if (msg.wind_speed !== undefined)
|
||||
parts.push({ val: msg.wind_speed, unit: msg.wind_unit || 'km/h', label: 'Wind', color: '#4aa3ff' });
|
||||
if (msg.rain !== undefined)
|
||||
parts.push({ val: msg.rain, unit: msg.rain_unit || 'mm', label: 'Rain', color: '#38bdf8' });
|
||||
|
||||
if (parts.length === 0)
|
||||
return `<div class="sdb-no-readings">No numeric data</div>`;
|
||||
|
||||
return parts.map(p => `
|
||||
<div class="sdb-reading">
|
||||
<div class="sdb-reading-val" style="color:${p.color}">${esc(String(p.val))}</div>
|
||||
<div class="sdb-reading-unit">${esc(p.unit)}</div>
|
||||
<div class="sdb-reading-label">${p.label}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function buildSparklineHTML(history, color) {
|
||||
if (history.length < 2)
|
||||
return `<div class="sdb-spark-placeholder">Collecting data…</div>`;
|
||||
|
||||
const W = 120, H = 22, PAD = 2;
|
||||
const min = Math.min(...history);
|
||||
const max = Math.max(...history);
|
||||
const range = max - min || 1;
|
||||
const pts = history.map((v, i) => {
|
||||
const x = (i / (history.length - 1)) * (W - PAD * 2) + PAD;
|
||||
const y = H - PAD - ((v - min) / range) * (H - PAD * 2);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
const last = pts.split(' ').pop().split(',');
|
||||
return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect fill="var(--bg-secondary)" width="${W}" height="${H}"/>
|
||||
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5" opacity="0.85"/>
|
||||
<circle cx="${last[0]}" cy="${last[1]}" r="2" fill="${color}"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function buildCardHTML(msg, history, primaryColor) {
|
||||
const age = formatAge(msg.timestamp);
|
||||
const fresh = isRecent(msg.timestamp);
|
||||
const batLow = msg.battery === 'LOW';
|
||||
const sparkHTML = history.length > 0
|
||||
? buildSparklineHTML(history, primaryColor || '#4aa3ff')
|
||||
: `<div class="sdb-spark-placeholder">Waiting for data…</div>`;
|
||||
|
||||
return `
|
||||
<div class="sdb-card-header">
|
||||
<div>
|
||||
<div class="sdb-name">${esc(msg.model || 'Unknown')}</div>
|
||||
<div class="sdb-id">ID ${esc(String(msg.id || 'N/A'))}${msg.channel ? ` · Ch ${esc(String(msg.channel))}` : ''}</div>
|
||||
</div>
|
||||
<div class="sdb-age${fresh ? ' sdb-age--fresh' : ''}">${age}</div>
|
||||
</div>
|
||||
<div class="sdb-readings">${buildReadingsHTML(msg)}</div>
|
||||
<div class="sdb-spark">${sparkHTML}</div>
|
||||
<div class="sdb-footer">
|
||||
${msg.battery ? `<span class="sdb-bat ${batLow ? 'sdb-bat--low' : 'sdb-bat--ok'}">● BAT ${esc(msg.battery)}</span>` : '<span></span>'}
|
||||
${msg.snr !== undefined ? `<span class="sdb-snr">SNR ${esc(String(msg.snr))} dB</span>` : '<span></span>'}
|
||||
${msg.frequency ? `<span class="sdb-freq">${esc(String(msg.frequency))}</span>` : '<span></span>'}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---- Public: reading hook ----
|
||||
|
||||
function addReading(msg) {
|
||||
const key = `${msg.model || 'Unknown'}_${msg.id || msg.channel || '0'}`;
|
||||
const primary = getPrimary(msg);
|
||||
|
||||
if (devices.has(key)) {
|
||||
const dev = devices.get(key);
|
||||
if (primary) {
|
||||
dev.history.push(primary.value);
|
||||
if (dev.history.length > MAX_SPARK_PTS) dev.history.shift();
|
||||
dev.primaryColor = primary.color;
|
||||
}
|
||||
dev.card.innerHTML = buildCardHTML(msg, dev.history, dev.primaryColor);
|
||||
const cls = getFlashClass(msg);
|
||||
dev.card.classList.add(cls);
|
||||
setTimeout(() => dev.card.classList.remove(cls), 820);
|
||||
} else {
|
||||
const history = primary ? [primary.value] : [];
|
||||
const grid = document.getElementById('sensorDashboardGrid');
|
||||
if (!grid) return;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'sdb-card sdb-card--new';
|
||||
card.innerHTML = buildCardHTML(msg, history, primary ? primary.color : '#4aa3ff');
|
||||
grid.insertBefore(card, grid.firstChild);
|
||||
setTimeout(() => card.classList.remove('sdb-card--new'), 2000);
|
||||
devices.set(key, { card, history, primaryColor: primary ? primary.color : '#4aa3ff' });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Show / hide / reset ----
|
||||
|
||||
function applyViewState(mode) {
|
||||
const view = document.getElementById('sensorDashboardView');
|
||||
const output = document.getElementById('output');
|
||||
|
||||
if (mode === 'sensor') {
|
||||
const saved = localStorage.getItem(STORAGE_KEY) || 'dashboard';
|
||||
const isDash = saved === 'dashboard';
|
||||
if (view) view.style.display = isDash ? 'block' : 'none';
|
||||
if (output) output.style.display = isDash ? 'none' : '';
|
||||
_updateToggle(isDash);
|
||||
} else {
|
||||
if (view) view.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function show() {
|
||||
localStorage.setItem(STORAGE_KEY, 'dashboard');
|
||||
applyViewState('sensor');
|
||||
}
|
||||
|
||||
function hide() {
|
||||
localStorage.setItem(STORAGE_KEY, 'feed');
|
||||
applyViewState('sensor');
|
||||
}
|
||||
|
||||
function _updateToggle(isDash) {
|
||||
document.getElementById('sensorToggleDash')?.classList.toggle('view-toggle-btn--active', isDash);
|
||||
document.getElementById('sensorToggleFeed')?.classList.toggle('view-toggle-btn--active', !isDash);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
devices.clear();
|
||||
const grid = document.getElementById('sensorDashboardGrid');
|
||||
if (grid) grid.innerHTML = '';
|
||||
}
|
||||
|
||||
return { addReading, show, hide, reset, applyViewState };
|
||||
})();
|
||||
+74
-27
@@ -118,6 +118,72 @@ const MapUtils = {
|
||||
return layer;
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a graticule (lat/lon grid) toggle button control to any Leaflet map.
|
||||
*
|
||||
* @param {L.Map} map
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.defaultVisible=true] Show grid on init.
|
||||
* @param {string} [options.position='bottomleft'] Leaflet control position.
|
||||
* @returns {{ control: L.Control, show: Function, hide: Function }}
|
||||
*/
|
||||
addGraticuleControl(map, options = {}) {
|
||||
const defaultVisible = options.defaultVisible !== false;
|
||||
const self = this;
|
||||
let graticuleLayer = null;
|
||||
let visible = false;
|
||||
let btnEl = null;
|
||||
let _onZoom = null;
|
||||
|
||||
const _build = () => {
|
||||
if (graticuleLayer) map.removeLayer(graticuleLayer);
|
||||
graticuleLayer = self._buildGraticule(map);
|
||||
graticuleLayer.addTo(map);
|
||||
};
|
||||
const show = () => {
|
||||
visible = true;
|
||||
_build();
|
||||
_onZoom = _build;
|
||||
map.on('zoomend', _onZoom);
|
||||
if (btnEl) btnEl.classList.add('active');
|
||||
};
|
||||
const hide = () => {
|
||||
visible = false;
|
||||
if (_onZoom) { map.off('zoomend', _onZoom); _onZoom = null; }
|
||||
if (graticuleLayer) { map.removeLayer(graticuleLayer); graticuleLayer = null; }
|
||||
if (btnEl) btnEl.classList.remove('active');
|
||||
};
|
||||
|
||||
const GraticuleControl = L.Control.extend({
|
||||
options: { position: options.position || 'bottomleft' },
|
||||
onAdd() {
|
||||
const btn = L.DomUtil.create('button', 'map-graticule-btn');
|
||||
btn.type = 'button';
|
||||
btn.title = 'Toggle coordinate grid';
|
||||
btn.setAttribute('aria-label', 'Toggle coordinate grid');
|
||||
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<line x1="0" y1="4.67" x2="14" y2="4.67" stroke="currentColor" stroke-width="1"/>
|
||||
<line x1="0" y1="9.33" x2="14" y2="9.33" stroke="currentColor" stroke-width="1"/>
|
||||
<line x1="4.67" y1="0" x2="4.67" y2="14" stroke="currentColor" stroke-width="1"/>
|
||||
<line x1="9.33" y1="0" x2="9.33" y2="14" stroke="currentColor" stroke-width="1"/>
|
||||
</svg>`;
|
||||
btnEl = btn;
|
||||
L.DomEvent.disableClickPropagation(btn);
|
||||
L.DomEvent.on(btn, 'click', () => { if (visible) hide(); else show(); });
|
||||
return btn;
|
||||
},
|
||||
onRemove() {
|
||||
hide();
|
||||
btnEl = null;
|
||||
},
|
||||
});
|
||||
|
||||
const control = new GraticuleControl();
|
||||
control.addTo(map);
|
||||
if (defaultVisible) show();
|
||||
return { control, show, hide };
|
||||
},
|
||||
|
||||
/**
|
||||
* Add tactical overlays to a map.
|
||||
*
|
||||
@@ -129,7 +195,7 @@ const MapUtils = {
|
||||
* { latlng: [lat,lng] }
|
||||
* @param {Object} [options.hudPanels]
|
||||
* { modeName: string, getContactCount: ()=>number, getSdrStatus: ()=>boolean }
|
||||
* @param {boolean} [options.graticule]
|
||||
* @param {boolean} [options.graticule=true] Pass false to start with grid hidden.
|
||||
* @param {boolean} [options.scaleBar]
|
||||
*
|
||||
* @returns {Object} handles
|
||||
@@ -174,32 +240,13 @@ const MapUtils = {
|
||||
handles.updateCount = hudHandles.updateCount;
|
||||
handles.updateStatus = hudHandles.updateStatus;
|
||||
|
||||
// --- Graticule ---
|
||||
let graticuleLayer = null;
|
||||
const buildGraticule = () => {
|
||||
if (graticuleLayer) map.removeLayer(graticuleLayer);
|
||||
graticuleLayer = this._buildGraticule(map);
|
||||
graticuleLayer.addTo(map);
|
||||
};
|
||||
const removeGraticule = () => {
|
||||
if (graticuleLayer) { map.removeLayer(graticuleLayer); graticuleLayer = null; }
|
||||
};
|
||||
if (options.graticule) {
|
||||
buildGraticule();
|
||||
map.on('zoomend', buildGraticule);
|
||||
cleanupFns.push(() => {
|
||||
map.off('zoomend', buildGraticule);
|
||||
removeGraticule();
|
||||
});
|
||||
}
|
||||
handles.showGraticule = () => {
|
||||
buildGraticule();
|
||||
map.on('zoomend', buildGraticule);
|
||||
};
|
||||
handles.hideGraticule = () => {
|
||||
map.off('zoomend', buildGraticule);
|
||||
removeGraticule();
|
||||
};
|
||||
// --- Graticule toggle control (always added; defaultVisible via options.graticule) ---
|
||||
const grat = this.addGraticuleControl(map, {
|
||||
defaultVisible: options.graticule !== false,
|
||||
});
|
||||
handles.showGraticule = grat.show;
|
||||
handles.hideGraticule = grat.hide;
|
||||
cleanupFns.push(() => grat.control.remove());
|
||||
|
||||
handles.removeAll = () => cleanupFns.forEach(fn => fn());
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
// Single source of truth for SPA mode wiring. Each entry drives (after the
|
||||
// derivation tasks that follow):
|
||||
// - modeCatalog (label/indicator/outputTitle/group)
|
||||
// - sidebar active-state toggles (elementId)
|
||||
// - the destroy map (destroy hook, or module.destroy?.())
|
||||
// - visuals container display (visuals: true)
|
||||
// - init dispatch in switchMode (init hook)
|
||||
//
|
||||
// Loaded in <head> before the DOM and before mode modules. init/destroy bodies
|
||||
// reference globals lazily (only called later from switchMode), so nothing here
|
||||
// is evaluated at load time.
|
||||
window.INTERCEPT_MODES = {
|
||||
pager: {
|
||||
label: 'Pager', indicator: 'PAGER', outputTitle: 'Pager Decoder', group: 'signals',
|
||||
elementId: 'pagerMode',
|
||||
visuals: false,
|
||||
destroy: () => { if (eventSource) { eventSource.close(); eventSource = null; } },
|
||||
},
|
||||
sensor: {
|
||||
label: '433MHz', indicator: '433MHZ', outputTitle: '433MHz Sensor Monitor', group: 'signals',
|
||||
elementId: 'sensorMode',
|
||||
visuals: false,
|
||||
destroy: () => { if (eventSource) { eventSource.close(); eventSource = null; } },
|
||||
},
|
||||
rtlamr: {
|
||||
label: 'Meters', indicator: 'METERS', outputTitle: 'Utility Meter Monitor', group: 'signals',
|
||||
elementId: 'rtlamrMode',
|
||||
visuals: false,
|
||||
destroy: () => { if (eventSource) { eventSource.close(); eventSource = null; } },
|
||||
},
|
||||
subghz: {
|
||||
label: 'SubGHz', indicator: 'SUBGHZ', outputTitle: 'SubGHz Transceiver', group: 'signals',
|
||||
elementId: 'subghzMode',
|
||||
visuals: true,
|
||||
module: 'SubGhz',
|
||||
init: () => {
|
||||
SubGhz.init();
|
||||
},
|
||||
},
|
||||
aprs: {
|
||||
label: 'APRS', indicator: 'APRS', outputTitle: 'APRS Tracker', group: 'tracking',
|
||||
elementId: 'aprsMode',
|
||||
visuals: true,
|
||||
destroy: () => {
|
||||
if (typeof destroyAprsMode === 'function') {
|
||||
destroyAprsMode();
|
||||
} else if (aprsEventSource) {
|
||||
aprsEventSource.close();
|
||||
aprsEventSource = null;
|
||||
}
|
||||
},
|
||||
init: () => {
|
||||
checkAprsTools();
|
||||
initAprsMap();
|
||||
// Fix map sizing on mobile after container becomes visible
|
||||
setTimeout(() => {
|
||||
if (aprsMap) aprsMap.invalidateSize();
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
gps: {
|
||||
label: 'GPS', indicator: 'GPS', outputTitle: 'GPS Receiver', group: 'tracking',
|
||||
elementId: 'gpsMode',
|
||||
visuals: true,
|
||||
module: 'GPS',
|
||||
init: () => {
|
||||
GPS.init();
|
||||
},
|
||||
},
|
||||
radiosonde: {
|
||||
label: 'Radiosonde', indicator: 'SONDE', outputTitle: 'Radiosonde Decoder', group: 'tracking',
|
||||
elementId: 'radiosondeMode',
|
||||
visuals: true,
|
||||
destroy: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } },
|
||||
init: () => {
|
||||
initRadiosondeWaveform();
|
||||
initRadiosondeMap();
|
||||
setTimeout(() => {
|
||||
if (radiosondeMap) radiosondeMap.invalidateSize();
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
satellite: {
|
||||
label: 'Satellite', indicator: 'SATELLITE', outputTitle: 'Satellite Monitor', group: 'space',
|
||||
elementId: 'satelliteMode',
|
||||
visuals: true,
|
||||
init: () => {
|
||||
initPolarPlot();
|
||||
initSatelliteList();
|
||||
},
|
||||
},
|
||||
sstv: {
|
||||
label: 'ISS SSTV', indicator: 'ISS SSTV', outputTitle: 'ISS SSTV Decoder', group: 'space',
|
||||
elementId: 'sstvMode',
|
||||
visuals: true,
|
||||
module: 'SSTV',
|
||||
init: () => {
|
||||
SSTV.init();
|
||||
setTimeout(() => {
|
||||
if (typeof SSTV !== 'undefined' && SSTV.invalidateMap) SSTV.invalidateMap();
|
||||
}, 120);
|
||||
},
|
||||
},
|
||||
weathersat: {
|
||||
label: 'Weather Sat', indicator: 'WEATHER SAT', outputTitle: 'Weather Satellite Decoder', group: 'space',
|
||||
elementId: 'weatherSatMode',
|
||||
visuals: true,
|
||||
module: 'WeatherSat',
|
||||
init: () => {
|
||||
WeatherSat.init();
|
||||
setTimeout(() => {
|
||||
WeatherSat.invalidateMap();
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
sstv_general: {
|
||||
label: 'HF SSTV', indicator: 'HF SSTV', outputTitle: 'HF SSTV Decoder', group: 'space',
|
||||
elementId: 'sstvGeneralMode',
|
||||
visuals: true,
|
||||
module: 'SSTVGeneral',
|
||||
init: () => {
|
||||
SSTVGeneral.init();
|
||||
},
|
||||
},
|
||||
wefax: {
|
||||
label: 'WeFax', indicator: 'WEFAX', outputTitle: 'Weather Fax Decoder', group: 'space',
|
||||
elementId: 'wefaxMode',
|
||||
visuals: true,
|
||||
module: 'WeFax',
|
||||
init: () => {
|
||||
WeFax.init();
|
||||
},
|
||||
},
|
||||
spaceweather: {
|
||||
label: 'Space Weather', indicator: 'SPACE WX', outputTitle: 'Space Weather Monitor', group: 'space',
|
||||
elementId: 'spaceWeatherMode',
|
||||
visuals: true,
|
||||
module: 'SpaceWeather',
|
||||
init: () => {
|
||||
SpaceWeather.init();
|
||||
},
|
||||
},
|
||||
meteor: {
|
||||
label: 'Meteor', indicator: 'METEOR', outputTitle: 'Meteor Scatter Monitor', group: 'space',
|
||||
elementId: 'meteorMode',
|
||||
visuals: true,
|
||||
module: 'MeteorScatter',
|
||||
init: () => {
|
||||
MeteorScatter.init();
|
||||
},
|
||||
},
|
||||
wifi: {
|
||||
label: 'WiFi', indicator: 'WIFI', outputTitle: 'WiFi Scanner', group: 'wireless',
|
||||
elementId: 'wifiMode',
|
||||
visuals: true,
|
||||
module: 'WiFiMode',
|
||||
init: () => {
|
||||
refreshWifiInterfaces();
|
||||
initRadar();
|
||||
initWatchList();
|
||||
// Initialize v2 WiFi components
|
||||
if (typeof WiFiMode !== 'undefined') {
|
||||
WiFiMode.init();
|
||||
}
|
||||
},
|
||||
},
|
||||
bluetooth: {
|
||||
label: 'Bluetooth', indicator: 'BLUETOOTH', outputTitle: 'Bluetooth Scanner', group: 'wireless',
|
||||
elementId: 'bluetoothMode',
|
||||
visuals: true,
|
||||
module: 'BluetoothMode',
|
||||
init: () => {
|
||||
refreshBtInterfaces();
|
||||
initBtRadar();
|
||||
},
|
||||
},
|
||||
bt_locate: {
|
||||
label: 'BT Locate', indicator: 'BT LOCATE', outputTitle: 'BT Locate — SAR Tracker', group: 'wireless',
|
||||
elementId: 'btLocateMode',
|
||||
visuals: true,
|
||||
module: 'BtLocate',
|
||||
init: () => {
|
||||
BtLocate.init();
|
||||
setTimeout(() => {
|
||||
if (typeof BtLocate !== 'undefined' && BtLocate.invalidateMap) BtLocate.invalidateMap();
|
||||
}, 100);
|
||||
setTimeout(() => {
|
||||
if (typeof BtLocate !== 'undefined' && BtLocate.invalidateMap) BtLocate.invalidateMap();
|
||||
}, 320);
|
||||
},
|
||||
},
|
||||
wifi_locate: {
|
||||
label: 'WiFi Locate', indicator: 'WF LOCATE', outputTitle: 'WiFi Locate', group: 'wireless',
|
||||
elementId: 'wflMode',
|
||||
visuals: true,
|
||||
module: 'WiFiLocate',
|
||||
init: () => {
|
||||
WiFiLocate.init();
|
||||
},
|
||||
},
|
||||
meshtastic: {
|
||||
label: 'Meshtastic', indicator: 'MESHTASTIC', outputTitle: 'Meshtastic Mesh Monitor', group: 'wireless',
|
||||
elementId: 'meshtasticMode',
|
||||
visuals: true,
|
||||
module: 'Meshtastic',
|
||||
init: () => {
|
||||
Meshtastic.init();
|
||||
// Fix map sizing after container becomes visible
|
||||
setTimeout(() => {
|
||||
Meshtastic.invalidateMap();
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
meshcore: {
|
||||
label: 'Meshcore', indicator: 'MESHCORE', outputTitle: 'Meshcore Mesh Monitor', group: 'wireless',
|
||||
elementId: 'meshcoreMode',
|
||||
visuals: true,
|
||||
module: 'MeshCore',
|
||||
init: () => {
|
||||
MeshCore.init();
|
||||
setTimeout(() => {
|
||||
MeshCore.invalidateMap();
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
tscm: {
|
||||
label: 'TSCM', indicator: 'TSCM', outputTitle: 'TSCM Counter-Surveillance', group: 'intel',
|
||||
elementId: 'tscmMode',
|
||||
visuals: true,
|
||||
destroy: () => { if (tscmEventSource) { tscmEventSource.close(); tscmEventSource = null; } },
|
||||
},
|
||||
drone: {
|
||||
label: 'Drone Intel', indicator: 'DRONE', outputTitle: 'Drone Intelligence', group: 'intel',
|
||||
elementId: 'droneMode',
|
||||
visuals: true,
|
||||
module: 'DroneMode',
|
||||
init: () => {
|
||||
if (typeof DroneMode !== 'undefined') {
|
||||
DroneMode.init();
|
||||
setTimeout(() => { DroneMode.invalidateMap?.(); }, 100);
|
||||
}
|
||||
},
|
||||
},
|
||||
spystations: {
|
||||
label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel',
|
||||
elementId: 'spystationsMode',
|
||||
visuals: true,
|
||||
module: 'SpyStations',
|
||||
init: () => {
|
||||
SpyStations.init();
|
||||
},
|
||||
},
|
||||
websdr: {
|
||||
label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel',
|
||||
elementId: 'websdrMode',
|
||||
visuals: true,
|
||||
module: 'WebSDR',
|
||||
init: () => {
|
||||
if (typeof initWebSDR === 'function') initWebSDR();
|
||||
},
|
||||
},
|
||||
waterfall: {
|
||||
label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals',
|
||||
elementId: 'waterfallMode',
|
||||
visuals: true,
|
||||
module: 'Waterfall',
|
||||
init: () => {
|
||||
if (typeof Waterfall !== 'undefined') Waterfall.init();
|
||||
},
|
||||
},
|
||||
morse: {
|
||||
label: 'Morse', indicator: 'MORSE', outputTitle: 'CW/Morse Decoder', group: 'signals',
|
||||
elementId: 'morseMode',
|
||||
visuals: true,
|
||||
module: 'MorseMode',
|
||||
init: () => {
|
||||
MorseMode.init();
|
||||
},
|
||||
},
|
||||
system: {
|
||||
label: 'System', indicator: 'SYSTEM', outputTitle: 'System Health Monitor', group: 'system',
|
||||
elementId: 'systemMode',
|
||||
visuals: true,
|
||||
module: 'SystemHealth',
|
||||
init: () => {
|
||||
SystemHealth.init();
|
||||
},
|
||||
},
|
||||
ook: {
|
||||
label: 'OOK Decoder', indicator: 'OOK', outputTitle: 'OOK Signal Decoder', group: 'signals',
|
||||
elementId: 'ookMode',
|
||||
visuals: true,
|
||||
module: 'OokMode',
|
||||
init: () => {
|
||||
OokMode.init();
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -213,6 +213,7 @@ const BtLocate = (function() {
|
||||
flushPendingHeatSync();
|
||||
scheduleMapStabilization();
|
||||
});
|
||||
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(map);
|
||||
}
|
||||
|
||||
// Init RSSI chart canvas
|
||||
|
||||
@@ -50,6 +50,7 @@ var DroneMode = (function () {
|
||||
maxZoom: 19,
|
||||
}).addTo(_map);
|
||||
}
|
||||
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(_map);
|
||||
}
|
||||
|
||||
function _connectSSE() {
|
||||
|
||||
@@ -330,6 +330,8 @@ const MeshCore = (function () {
|
||||
Settings.registerMap(_map);
|
||||
}).catch(e => console.warn('MeshCore: Settings init failed, using fallback tiles:', e));
|
||||
}
|
||||
|
||||
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(_map);
|
||||
}
|
||||
|
||||
function _updateMapMarker(node) {
|
||||
|
||||
@@ -137,6 +137,8 @@ const Meshtastic = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(meshMap);
|
||||
|
||||
// Handle resize
|
||||
setTimeout(() => {
|
||||
if (meshMap) meshMap.invalidateSize();
|
||||
|
||||
@@ -241,6 +241,8 @@ const SSTV = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(issMap);
|
||||
|
||||
// Create ISS icon
|
||||
const issIcon = L.divIcon({
|
||||
className: 'sstv-iss-marker',
|
||||
|
||||
@@ -23,7 +23,6 @@ const WeatherSat = (function() {
|
||||
let groundMap = null;
|
||||
let groundTrackLayer = null;
|
||||
let groundOverlayLayer = null;
|
||||
let groundGridLayer = null;
|
||||
let satCrosshairMarker = null;
|
||||
let observerMarker = null;
|
||||
let consoleEntries = [];
|
||||
@@ -1086,8 +1085,7 @@ const WeatherSat = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
groundGridLayer = L.layerGroup().addTo(groundMap);
|
||||
addStyledGridOverlay(groundGridLayer);
|
||||
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(groundMap);
|
||||
|
||||
groundTrackLayer = L.layerGroup().addTo(groundMap);
|
||||
groundOverlayLayer = L.layerGroup().addTo(groundMap);
|
||||
@@ -1145,38 +1143,6 @@ const WeatherSat = (function() {
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a subtle graticule over the base map for a cyber/wireframe look.
|
||||
*/
|
||||
function addStyledGridOverlay(layer) {
|
||||
if (!layer || typeof L === 'undefined') return;
|
||||
layer.clearLayers();
|
||||
|
||||
for (let lon = -180; lon <= 180; lon += 30) {
|
||||
const line = [];
|
||||
for (let lat = -85; lat <= 85; lat += 5) line.push([lat, lon]);
|
||||
L.polyline(line, {
|
||||
color: '#4ed2ff',
|
||||
weight: lon % 60 === 0 ? 1.1 : 0.8,
|
||||
opacity: lon % 60 === 0 ? 0.2 : 0.12,
|
||||
interactive: false,
|
||||
lineCap: 'round',
|
||||
}).addTo(layer);
|
||||
}
|
||||
|
||||
for (let lat = -75; lat <= 75; lat += 15) {
|
||||
const line = [];
|
||||
for (let lon = -180; lon <= 180; lon += 5) line.push([lat, lon]);
|
||||
L.polyline(line, {
|
||||
color: '#5be7ff',
|
||||
weight: lat % 30 === 0 ? 1.1 : 0.8,
|
||||
opacity: lat % 30 === 0 ? 0.2 : 0.12,
|
||||
interactive: false,
|
||||
lineCap: 'round',
|
||||
}).addTo(layer);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSatelliteCrosshair() {
|
||||
if (!groundOverlayLayer || !satCrosshairMarker) return;
|
||||
groundOverlayLayer.removeLayer(satCrosshairMarker);
|
||||
|
||||
@@ -343,6 +343,7 @@ async function initWebsdrLeaflet(mapEl) {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(websdrMap);
|
||||
mapEl.style.background = '#1a1d29';
|
||||
return true;
|
||||
}
|
||||
|
||||
+3
-12
@@ -32,16 +32,6 @@ const WiFiMode = (function() {
|
||||
// Agent Support
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get the API base URL, routing through agent proxy if agent is selected.
|
||||
*/
|
||||
function getApiBase() {
|
||||
if (typeof currentAgent !== 'undefined' && currentAgent !== 'local') {
|
||||
return `/controller/agents/${currentAgent}/wifi/v2`;
|
||||
}
|
||||
return CONFIG.apiBase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current agent name for tagging data.
|
||||
*/
|
||||
@@ -1353,7 +1343,7 @@ const WiFiMode = (function() {
|
||||
|
||||
if (isAgentMode) {
|
||||
// Route through agent proxy
|
||||
response = await fetch(`/controller/agents/${currentAgent}/wifi/v2/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
|
||||
response = await fetch(`/controller/agents/${currentAgent}/proxy/wifi/v2/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
|
||||
} else {
|
||||
response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
|
||||
}
|
||||
@@ -1371,7 +1361,8 @@ const WiFiMode = (function() {
|
||||
const data = await response.json();
|
||||
// Handle agent response format (may be nested in 'result')
|
||||
const result = isAgentMode && data.result ? data.result : data;
|
||||
const clientList = result.clients || [];
|
||||
// /wifi/v2/clients returns a bare array; tolerate {clients: [...]} too
|
||||
const clientList = Array.isArray(result) ? result : (result.clients || []);
|
||||
|
||||
if (clientList.length > 0) {
|
||||
renderClientList(clientList, bssid);
|
||||
|
||||
+116
-195
@@ -56,6 +56,8 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/pager-directory.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/sensor-dashboard.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-timeline.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/device-cards.css') }}">
|
||||
@@ -770,8 +772,6 @@
|
||||
|
||||
{% include 'partials/modes/tscm.html' %}
|
||||
|
||||
{% include 'partials/modes/ais.html' %}
|
||||
|
||||
{% include 'partials/modes/drone.html' %}
|
||||
|
||||
{% include 'partials/modes/radiosonde.html' %}
|
||||
@@ -809,10 +809,18 @@
|
||||
<div title="Total Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg></span> <span id="msgCount">0</span></div>
|
||||
<div title="POCSAG Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span> <span id="pocsagCount">0</span></div>
|
||||
<div title="FLEX Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span> <span id="flexCount">0</span></div>
|
||||
<div class="view-toggle-group">
|
||||
<button class="view-toggle-btn view-toggle-btn--active" id="pagerToggleDir" onclick="PagerDirectory.show()">Directory</button>
|
||||
<button class="view-toggle-btn" id="pagerToggleFeed" onclick="PagerDirectory.hide()">Feed</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats" id="sensorStats">
|
||||
<div title="Unique Sensors"><span class="icon icon--sm"><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></span> <span id="sensorCount">0</span></div>
|
||||
<div title="Device Types"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg></span> <span id="deviceCount">0</span></div>
|
||||
<div class="view-toggle-group">
|
||||
<button class="view-toggle-btn view-toggle-btn--active" id="sensorToggleDash" onclick="SensorDashboard.show()">Dashboard</button>
|
||||
<button class="view-toggle-btn" id="sensorToggleFeed" onclick="SensorDashboard.hide()">Feed</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats" id="wifiStats">
|
||||
<div title="Access Points"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg></span> <span id="apCount">0</span></div>
|
||||
@@ -3597,6 +3605,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="signalViewWrap">
|
||||
<div id="pagerDirectoryView" class="pdir-panel" style="display:none;">
|
||||
<div class="pdir-header">Sources — <span id="pagerDirCount">0</span> active</div>
|
||||
<div id="pagerDirEntries" class="pdir-entries"></div>
|
||||
</div>
|
||||
<div id="sensorDashboardView" class="sdb-view" style="display:none;">
|
||||
<div id="sensorDashboardGrid" class="sdb-grid"></div>
|
||||
</div>
|
||||
<div class="pdir-feed-col">
|
||||
<div class="pdir-feed-header" id="pagerFeedHeader" style="display:none;">
|
||||
<span id="pagerFeedLabel">All messages</span>
|
||||
<button id="pagerClearHighlight" class="pdir-clear-btn" onclick="PagerDirectory.clearHighlight()" style="display:none;">clear highlight</button>
|
||||
</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">
|
||||
@@ -3605,6 +3626,8 @@
|
||||
<p>Configure settings and click "Start Decoding" to begin.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- .pdir-feed-col -->
|
||||
</div><!-- #signalViewWrap -->
|
||||
|
||||
<div class="status-bar">
|
||||
<div class="status-indicator">
|
||||
@@ -3639,6 +3662,8 @@
|
||||
<script src="{{ url_for('static', filename='js/components/radio-knob.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/signal-guess.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/signal-cards.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/pager-directory.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/sensor-dashboard.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/signal-timeline.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/activity-timeline.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/timeline-adapters/rf-adapter.js') }}"></script>
|
||||
@@ -3659,6 +3684,7 @@
|
||||
<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>
|
||||
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/mode-registry.js') }}?v={{ version }}"></script>
|
||||
|
||||
<script>
|
||||
// ============================================
|
||||
@@ -3789,37 +3815,19 @@
|
||||
}
|
||||
|
||||
// Mode from query string (e.g., /?mode=wifi)
|
||||
if (!window.INTERCEPT_MODES) {
|
||||
throw new Error('mode-registry.js failed to load — the SPA cannot start');
|
||||
}
|
||||
let pendingStartMode = null;
|
||||
const modeCatalog = {
|
||||
pager: { label: 'Pager', indicator: 'PAGER', outputTitle: 'Pager Decoder', group: 'signals' },
|
||||
sensor: { label: '433MHz', indicator: '433MHZ', outputTitle: '433MHz Sensor Monitor', group: 'signals' },
|
||||
rtlamr: { label: 'Meters', indicator: 'METERS', outputTitle: 'Utility Meter Monitor', group: 'signals' },
|
||||
subghz: { label: 'SubGHz', indicator: 'SUBGHZ', outputTitle: 'SubGHz Transceiver', group: 'signals' },
|
||||
aprs: { label: 'APRS', indicator: 'APRS', outputTitle: 'APRS Tracker', group: 'tracking' },
|
||||
gps: { label: 'GPS', indicator: 'GPS', outputTitle: 'GPS Receiver', group: 'tracking' },
|
||||
radiosonde: { label: 'Radiosonde', indicator: 'SONDE', outputTitle: 'Radiosonde Decoder', group: 'tracking' },
|
||||
satellite: { label: 'Satellite', indicator: 'SATELLITE', outputTitle: 'Satellite Monitor', group: 'space' },
|
||||
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' },
|
||||
meteor: { label: 'Meteor', indicator: 'METEOR', outputTitle: 'Meteor Scatter Monitor', group: 'space' },
|
||||
wifi: { label: 'WiFi', indicator: 'WIFI', outputTitle: 'WiFi Scanner', group: 'wireless' },
|
||||
bluetooth: { label: 'Bluetooth', indicator: 'BLUETOOTH', outputTitle: 'Bluetooth Scanner', group: 'wireless' },
|
||||
bt_locate: { label: 'BT Locate', indicator: 'BT LOCATE', outputTitle: 'BT Locate — SAR Tracker', group: 'wireless' },
|
||||
wifi_locate: { label: 'WiFi Locate', indicator: 'WF LOCATE', outputTitle: 'WiFi Locate', group: 'wireless' },
|
||||
meshtastic: { label: 'Meshtastic', indicator: 'MESHTASTIC', outputTitle: 'Meshtastic Mesh Monitor', group: 'wireless' },
|
||||
meshcore: { label: 'Meshcore', indicator: 'MESHCORE', outputTitle: 'Meshcore Mesh Monitor', group: 'wireless' },
|
||||
tscm: { label: 'TSCM', indicator: 'TSCM', outputTitle: 'TSCM Counter-Surveillance', group: 'intel' },
|
||||
drone: { label: 'Drone Intel', indicator: 'DRONE', outputTitle: 'Drone Intelligence', group: 'intel' },
|
||||
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' },
|
||||
system: { label: 'System', indicator: 'SYSTEM', outputTitle: 'System Health Monitor', group: 'system' },
|
||||
ook: { label: 'OOK Decoder', indicator: 'OOK', outputTitle: 'OOK Signal Decoder', group: 'signals' },
|
||||
};
|
||||
const modeCatalog = {};
|
||||
for (const [mode, def] of Object.entries(window.INTERCEPT_MODES)) {
|
||||
modeCatalog[mode] = {
|
||||
label: def.label,
|
||||
indicator: def.indicator,
|
||||
outputTitle: def.outputTitle,
|
||||
group: def.group,
|
||||
};
|
||||
}
|
||||
const validModes = new Set(Object.keys(modeCatalog));
|
||||
window.interceptModeCatalog = Object.assign({}, modeCatalog);
|
||||
|
||||
@@ -4413,46 +4421,16 @@
|
||||
// Shared module destroy map — closes SSE EventSources, timers, etc.
|
||||
// Used by both switchMode() and dashboard navigation cleanup.
|
||||
function getModuleDestroyFn(mode) {
|
||||
const moduleDestroyMap = {
|
||||
pager: () => { if (eventSource) { eventSource.close(); eventSource = null; } },
|
||||
sensor: () => { if (eventSource) { eventSource.close(); eventSource = null; } },
|
||||
rtlamr: () => { if (eventSource) { eventSource.close(); eventSource = null; } },
|
||||
subghz: () => typeof SubGhz !== 'undefined' && SubGhz.destroy(),
|
||||
morse: () => typeof MorseMode !== 'undefined' && MorseMode.destroy?.(),
|
||||
spaceweather: () => typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy?.(),
|
||||
weathersat: () => typeof WeatherSat !== 'undefined' && WeatherSat.destroy?.(),
|
||||
wefax: () => typeof WeFax !== 'undefined' && WeFax.destroy?.(),
|
||||
system: () => typeof SystemHealth !== 'undefined' && SystemHealth.destroy?.(),
|
||||
waterfall: () => typeof Waterfall !== 'undefined' && Waterfall.destroy?.(),
|
||||
gps: () => typeof GPS !== 'undefined' && GPS.destroy?.(),
|
||||
meshtastic: () => typeof Meshtastic !== 'undefined' && Meshtastic.destroy?.(),
|
||||
meshcore: () => typeof MeshCore !== 'undefined' && MeshCore.destroy?.(),
|
||||
bluetooth: () => typeof BluetoothMode !== 'undefined' && BluetoothMode.destroy?.(),
|
||||
wifi: () => typeof WiFiMode !== 'undefined' && WiFiMode.destroy?.(),
|
||||
bt_locate: () => typeof BtLocate !== 'undefined' && BtLocate.destroy?.(),
|
||||
wifi_locate: () => typeof WiFiLocate !== 'undefined' && WiFiLocate.destroy?.(),
|
||||
sstv: () => typeof SSTV !== 'undefined' && SSTV.destroy?.(),
|
||||
sstv_general: () => typeof SSTVGeneral !== 'undefined' && SSTVGeneral.destroy?.(),
|
||||
websdr: () => typeof WebSDR !== 'undefined' && WebSDR.destroy?.(),
|
||||
spystations: () => typeof SpyStations !== 'undefined' && SpyStations.destroy?.(),
|
||||
ais: () => { if (aisEventSource) { aisEventSource.close(); aisEventSource = null; } },
|
||||
acars: () => { if (acarsMainEventSource) { acarsMainEventSource.close(); acarsMainEventSource = null; } },
|
||||
vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } },
|
||||
radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } },
|
||||
aprs: () => {
|
||||
if (typeof destroyAprsMode === 'function') {
|
||||
destroyAprsMode();
|
||||
} else if (aprsEventSource) {
|
||||
aprsEventSource.close();
|
||||
aprsEventSource = null;
|
||||
}
|
||||
},
|
||||
tscm: () => { if (tscmEventSource) { tscmEventSource.close(); tscmEventSource = null; } },
|
||||
meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(),
|
||||
ook: () => typeof OokMode !== 'undefined' && OokMode.destroy?.(),
|
||||
drone: () => typeof DroneMode !== 'undefined' && DroneMode.destroy?.(),
|
||||
};
|
||||
return moduleDestroyMap[mode] || null;
|
||||
const def = window.INTERCEPT_MODES[mode];
|
||||
if (!def) return null;
|
||||
if (def.destroy) return def.destroy;
|
||||
if (def.module) {
|
||||
return () => {
|
||||
const mod = window[def.module];
|
||||
if (mod && typeof mod.destroy === 'function') mod.destroy();
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function destroyCurrentMode() {
|
||||
@@ -4582,6 +4560,31 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (!window._dashboardHomeBtnBound) {
|
||||
window._dashboardHomeBtnBound = true;
|
||||
document.addEventListener('click', (event) => {
|
||||
if (event.defaultPrevented || event.button !== 0) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
const link = event.target && event.target.closest
|
||||
? event.target.closest('.nav-dashboard-btn')
|
||||
: null;
|
||||
if (!link) return;
|
||||
try {
|
||||
const href = new URL(link.href, window.location.href);
|
||||
if (href.origin !== window.location.origin || href.pathname !== '/') return;
|
||||
} catch (_) { return; }
|
||||
event.preventDefault();
|
||||
stopActiveLocalScansForNavigation();
|
||||
destroyCurrentMode();
|
||||
const welcome = document.getElementById('welcomePage');
|
||||
if (welcome) {
|
||||
welcome.classList.remove('fade-out');
|
||||
welcome.style.display = '';
|
||||
}
|
||||
window.history.pushState({}, '', '/');
|
||||
});
|
||||
}
|
||||
|
||||
function postStopRequest(url, timeoutMs = LOCAL_STOP_TIMEOUT_MS) {
|
||||
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||||
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||
@@ -4753,35 +4756,11 @@
|
||||
if (activeMobileBtn) {
|
||||
activeMobileBtn.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||
}
|
||||
document.getElementById('pagerMode')?.classList.toggle('active', mode === 'pager');
|
||||
document.getElementById('sensorMode')?.classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
||||
document.getElementById('satelliteMode')?.classList.toggle('active', mode === 'satellite');
|
||||
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');
|
||||
document.getElementById('btLocateMode')?.classList.toggle('active', mode === 'bt_locate');
|
||||
document.getElementById('wflMode')?.classList.toggle('active', mode === 'wifi_locate');
|
||||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
|
||||
document.getElementById('droneMode')?.classList.toggle('active', mode === 'drone');
|
||||
document.getElementById('radiosondeMode')?.classList.toggle('active', mode === 'radiosonde');
|
||||
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||||
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
||||
document.getElementById('meshcoreMode')?.classList.toggle('active', mode === 'meshcore');
|
||||
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
||||
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');
|
||||
document.getElementById('meteorMode')?.classList.toggle('active', mode === 'meteor');
|
||||
document.getElementById('systemMode')?.classList.toggle('active', mode === 'system');
|
||||
document.getElementById('ookMode')?.classList.toggle('active', mode === 'ook');
|
||||
for (const [m, def] of Object.entries(window.INTERCEPT_MODES)) {
|
||||
if (def.elementId) {
|
||||
document.getElementById(def.elementId)?.classList.toggle('active', mode === m);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
document.getElementById('pagerStats')?.classList.toggle('active', mode === 'pager');
|
||||
@@ -4873,8 +4852,18 @@
|
||||
|
||||
// Hide the signal feed output for modes that have their own visuals
|
||||
const outputEl = document.getElementById('output');
|
||||
const modesWithVisuals = ['satellite', 'sstv', 'weathersat', 'sstv_general', 'wefax', 'aprs', 'wifi', 'bluetooth', 'tscm', 'spystations', 'meshtastic', 'meshcore', 'websdr', 'subghz', 'spaceweather', 'bt_locate', 'wifi_locate', 'waterfall', 'morse', 'meteor', 'system', 'ook', 'radiosonde', 'gps', 'drone'];
|
||||
if (outputEl) outputEl.style.display = modesWithVisuals.includes(mode) ? 'none' : 'block';
|
||||
const signalViewWrapEl = document.getElementById('signalViewWrap');
|
||||
const modesWithVisuals = Object.keys(window.INTERCEPT_MODES)
|
||||
.filter((m) => window.INTERCEPT_MODES[m].visuals);
|
||||
if (modesWithVisuals.includes(mode)) {
|
||||
if (signalViewWrapEl) signalViewWrapEl.style.display = 'none';
|
||||
if (outputEl) outputEl.style.display = 'none';
|
||||
} else {
|
||||
if (signalViewWrapEl) signalViewWrapEl.style.display = '';
|
||||
if (outputEl) outputEl.style.display = 'block';
|
||||
}
|
||||
if (typeof PagerDirectory !== 'undefined') PagerDirectory.applyViewState(mode);
|
||||
if (typeof SensorDashboard !== 'undefined') SensorDashboard.applyViewState(mode);
|
||||
|
||||
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
|
||||
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
|
||||
@@ -4932,7 +4921,7 @@
|
||||
refreshDroneDevices();
|
||||
}
|
||||
|
||||
// Module destroy is now handled by moduleDestroyMap above.
|
||||
// Module destroy is now handled by the mode registry (static/js/mode-registry.js).
|
||||
|
||||
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
@@ -4945,7 +4934,7 @@
|
||||
|
||||
// Show agent selector for modes that support remote agents
|
||||
const agentSection = document.getElementById('agentSection');
|
||||
const agentModes = ['pager', 'sensor', 'rtlamr', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm', 'ais'];
|
||||
const agentModes = ['pager', 'sensor', 'rtlamr', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm'];
|
||||
if (agentSection) agentSection.style.display = agentModes.includes(mode) ? 'block' : 'none';
|
||||
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
@@ -5003,97 +4992,17 @@
|
||||
}
|
||||
|
||||
// Load interfaces and initialize visualizations when switching modes
|
||||
if (mode === 'wifi') {
|
||||
refreshWifiInterfaces();
|
||||
initRadar();
|
||||
initWatchList();
|
||||
// Initialize v2 WiFi components
|
||||
if (typeof WiFiMode !== 'undefined') {
|
||||
WiFiMode.init();
|
||||
}
|
||||
} else if (mode === 'bluetooth') {
|
||||
refreshBtInterfaces();
|
||||
initBtRadar();
|
||||
} else if (mode === 'aprs') {
|
||||
checkAprsTools();
|
||||
initAprsMap();
|
||||
// Fix map sizing on mobile after container becomes visible
|
||||
setTimeout(() => {
|
||||
if (aprsMap) aprsMap.invalidateSize();
|
||||
}, 100);
|
||||
} else if (mode === 'satellite') {
|
||||
initPolarPlot();
|
||||
initSatelliteList();
|
||||
} else if (mode === 'spystations') {
|
||||
SpyStations.init();
|
||||
} else if (mode === 'meshtastic') {
|
||||
Meshtastic.init();
|
||||
// Fix map sizing after container becomes visible
|
||||
setTimeout(() => {
|
||||
Meshtastic.invalidateMap();
|
||||
}, 100);
|
||||
} else if (mode === 'meshcore') {
|
||||
MeshCore.init();
|
||||
setTimeout(() => {
|
||||
MeshCore.invalidateMap();
|
||||
}, 100);
|
||||
} else if (mode === 'sstv') {
|
||||
SSTV.init();
|
||||
setTimeout(() => {
|
||||
if (typeof SSTV !== 'undefined' && SSTV.invalidateMap) SSTV.invalidateMap();
|
||||
}, 120);
|
||||
} else if (mode === 'weathersat') {
|
||||
WeatherSat.init();
|
||||
setTimeout(() => {
|
||||
WeatherSat.invalidateMap();
|
||||
}, 100);
|
||||
} else if (mode === 'sstv_general') {
|
||||
SSTVGeneral.init();
|
||||
} else if (mode === 'gps') {
|
||||
GPS.init();
|
||||
} else if (mode === 'websdr') {
|
||||
if (typeof initWebSDR === 'function') initWebSDR();
|
||||
} else if (mode === 'subghz') {
|
||||
SubGhz.init();
|
||||
} else if (mode === 'bt_locate') {
|
||||
BtLocate.init();
|
||||
setTimeout(() => {
|
||||
if (typeof BtLocate !== 'undefined' && BtLocate.invalidateMap) BtLocate.invalidateMap();
|
||||
}, 100);
|
||||
setTimeout(() => {
|
||||
if (typeof BtLocate !== 'undefined' && BtLocate.invalidateMap) BtLocate.invalidateMap();
|
||||
}, 320);
|
||||
} else if (mode === 'wifi_locate') {
|
||||
WiFiLocate.init();
|
||||
} 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();
|
||||
} else if (mode === 'radiosonde') {
|
||||
initRadiosondeWaveform();
|
||||
initRadiosondeMap();
|
||||
setTimeout(() => {
|
||||
if (radiosondeMap) radiosondeMap.invalidateSize();
|
||||
}, 100);
|
||||
} else if (mode === 'meteor') {
|
||||
MeteorScatter.init();
|
||||
} else if (mode === 'system') {
|
||||
SystemHealth.init();
|
||||
} else if (mode === 'ook') {
|
||||
OokMode.init();
|
||||
} else if (mode === 'drone') {
|
||||
if (typeof DroneMode !== 'undefined') {
|
||||
DroneMode.init();
|
||||
setTimeout(() => { DroneMode.invalidateMap?.(); }, 100);
|
||||
const modeDef = window.INTERCEPT_MODES[mode];
|
||||
if (modeDef && typeof modeDef.init === 'function') {
|
||||
try {
|
||||
modeDef.init();
|
||||
} catch (err) {
|
||||
console.error(`Mode init failed for ${mode}:`, err);
|
||||
}
|
||||
}
|
||||
if (requestId !== modeSwitchRequestId) return;
|
||||
|
||||
// Waterfall destroy is now handled by moduleDestroyMap above.
|
||||
// Waterfall destroy is now handled by the mode registry (static/js/mode-registry.js).
|
||||
|
||||
const totalMs = Math.round(performance.now() - switchStartMs);
|
||||
console.info(
|
||||
@@ -5121,6 +5030,13 @@
|
||||
const mode = getModeFromQuery();
|
||||
if (mode && mode !== currentMode) {
|
||||
switchMode(mode, { updateUrl: false });
|
||||
} else if (!mode) {
|
||||
destroyCurrentMode();
|
||||
const welcome = document.getElementById('welcomePage');
|
||||
if (welcome) {
|
||||
welcome.classList.remove('fade-out');
|
||||
welcome.style.display = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5767,8 +5683,11 @@
|
||||
msg.rain_unit = 'mm';
|
||||
}
|
||||
|
||||
if (data.snr !== undefined) msg.snr = data.snr;
|
||||
if (data.rssi !== undefined) msg.rssi = data.rssi;
|
||||
// Create card using SignalCards component
|
||||
const card = SignalCards.createSensorCard(msg);
|
||||
if (typeof SensorDashboard !== 'undefined') SensorDashboard.addReading(msg);
|
||||
output.insertBefore(card, output.firstChild);
|
||||
|
||||
// Add to activity timeline
|
||||
@@ -6434,7 +6353,7 @@
|
||||
document.getElementById('capGainRange').textContent = `${caps.gain_min}-${caps.gain_max} dB`;
|
||||
// Update max attribute on all mode gain inputs so constraints match the SDR
|
||||
const gainMax = caps.gain_max;
|
||||
['gain', 'sensorGain', 'aisGainInput', 'acarsGainInput', 'aprsStripGain', 'weatherSatGain'].forEach(id => {
|
||||
['gain', 'sensorGain', 'aprsStripGain', 'weatherSatGain'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.max = gainMax;
|
||||
});
|
||||
@@ -6562,8 +6481,7 @@
|
||||
// Warn if any SDR mode is currently running — bias-T is applied at
|
||||
// start time and cannot be toggled on a running device.
|
||||
const anyRunning = isRunning || isSensorRunning
|
||||
|| (typeof isAdsbRunning !== 'undefined' && isAdsbRunning)
|
||||
|| (typeof isAisRunning !== 'undefined' && isAisRunning);
|
||||
|| (typeof isAdsbRunning !== 'undefined' && isAdsbRunning);
|
||||
if (anyRunning) {
|
||||
showInfo('Bias-T change will take effect after restarting the active SDR mode');
|
||||
}
|
||||
@@ -7246,6 +7164,7 @@
|
||||
// Use SignalCards component to create the message card (auto-detects status)
|
||||
const msgEl = SignalCards.createPagerCard(msg);
|
||||
|
||||
if (typeof PagerDirectory !== 'undefined') PagerDirectory.addMessage(msg);
|
||||
output.insertBefore(msgEl, output.firstChild);
|
||||
|
||||
// Add to activity timeline
|
||||
@@ -7343,6 +7262,8 @@
|
||||
Messages cleared. ${isRunning || isSensorRunning ? 'Waiting for new messages...' : 'Start decoding to receive messages.'}
|
||||
</div>
|
||||
`;
|
||||
if (typeof PagerDirectory !== 'undefined') PagerDirectory.reset();
|
||||
if (typeof SensorDashboard !== 'undefined') SensorDashboard.reset();
|
||||
msgCount = 0;
|
||||
pocsagCount = 0;
|
||||
flexCount = 0;
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
<!-- ACARS AIRCRAFT MESSAGING MODE -->
|
||||
<div id="acarsMode" class="mode-content" style="display: none;">
|
||||
<div class="section">
|
||||
<h3>ACARS Messaging</h3>
|
||||
<div class="info-text" style="margin-bottom: 15px;">
|
||||
Decode ACARS (Aircraft Communications Addressing and Reporting System) messages on VHF frequencies (~129-131 MHz). Captures flight data, weather reports, position updates, and operational messages from aircraft.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Region & Frequencies</h3>
|
||||
<div class="form-group">
|
||||
<label>Region</label>
|
||||
<select id="acarsRegionSelect" onchange="updateAcarsMainFreqs()" style="width: 100%;">
|
||||
<option value="na">North America</option>
|
||||
<option value="eu">Europe</option>
|
||||
<option value="ap">Asia-Pacific</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="acarsMainFreqSelector" style="display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; font-size: 11px;">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Gain (dB, 0 = auto)</label>
|
||||
<input type="number" id="acarsGainInput" value="40" min="0" max="50" placeholder="0-50">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Status</h3>
|
||||
<div id="acarsStatusDisplay" class="info-text">
|
||||
<p>Status: <span id="acarsStatusText" style="color: var(--accent-yellow);">Standby</span></p>
|
||||
<p>Messages: <span id="acarsMessageCount">0</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide -->
|
||||
<div class="section">
|
||||
<h3>Antenna Guide</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||
VHF Airband (~130 MHz) — stock SDR antenna may work at close range
|
||||
</p>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Element length:</strong> ~57 cm each (quarter-wave at 130 MHz)</li>
|
||||
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
|
||||
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (airband is vertically polarized)</li>
|
||||
<li><strong style="color: var(--text-primary);">Placement:</strong> Outdoors, as high as possible with clear sky view</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||
<strong style="color: var(--accent-cyan); 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);">Primary (N. America)</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">131.550 / 130.025 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">57 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">AM MSK 2400 baud</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="startAcarsBtn" onclick="startAcarsMode()">
|
||||
Start ACARS
|
||||
</button>
|
||||
<button class="stop-btn" id="stopAcarsBtn" onclick="stopAcarsMode()" style="display: none;">
|
||||
Stop ACARS
|
||||
</button>
|
||||
|
||||
<!-- Live Message Feed -->
|
||||
<div class="section" id="acarsMessageFeedSection" style="margin-top: 15px;">
|
||||
<h3>Message Feed</h3>
|
||||
<div id="acarsMessageFeed" class="acars-message-feed" style="max-height: 400px; overflow-y: auto; font-size: 11px;">
|
||||
<div style="color: var(--text-muted); font-style: italic; padding: 10px 0;">Start ACARS to see live messages</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let acarsMainEventSource = null;
|
||||
let acarsMainMsgCount = 0;
|
||||
|
||||
const acarsMainFrequencies = {
|
||||
'na': ['131.550', '130.025', '129.125'],
|
||||
'eu': ['131.525', '131.725', '131.550'],
|
||||
'ap': ['131.550', '131.450']
|
||||
};
|
||||
|
||||
function updateAcarsMainFreqs() {
|
||||
const region = document.getElementById('acarsRegionSelect').value;
|
||||
const freqs = acarsMainFrequencies[region] || acarsMainFrequencies['na'];
|
||||
const container = document.getElementById('acarsMainFreqSelector');
|
||||
|
||||
const previouslyChecked = new Set();
|
||||
container.querySelectorAll('input:checked').forEach(cb => previouslyChecked.add(cb.value));
|
||||
|
||||
container.innerHTML = freqs.map((freq, i) => {
|
||||
const checked = previouslyChecked.size === 0 || previouslyChecked.has(freq) ? 'checked' : '';
|
||||
return `
|
||||
<label style="display: flex; align-items: center; gap: 3px; padding: 2px 6px; background: var(--bg-secondary); border-radius: 3px; cursor: pointer;">
|
||||
<input type="checkbox" class="acars-main-freq-cb" value="${freq}" ${checked} style="margin: 0; cursor: pointer;">
|
||||
<span>${freq}</span>
|
||||
</label>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getAcarsMainSelectedFreqs() {
|
||||
const checkboxes = document.querySelectorAll('.acars-main-freq-cb:checked');
|
||||
const selected = Array.from(checkboxes).map(cb => cb.value);
|
||||
if (selected.length === 0) {
|
||||
const region = document.getElementById('acarsRegionSelect').value;
|
||||
return acarsMainFrequencies[region] || acarsMainFrequencies['na'];
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function startAcarsMode() {
|
||||
const gain = document.getElementById('acarsGainInput').value || '40';
|
||||
const device = document.getElementById('deviceSelect')?.value || '0';
|
||||
const frequencies = getAcarsMainSelectedFreqs();
|
||||
|
||||
fetch('/acars/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device, gain, frequencies })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'started') {
|
||||
document.getElementById('startAcarsBtn').style.display = 'none';
|
||||
document.getElementById('stopAcarsBtn').style.display = 'block';
|
||||
document.getElementById('acarsStatusText').textContent = 'Listening';
|
||||
document.getElementById('acarsStatusText').style.color = 'var(--accent-green)';
|
||||
acarsMainMsgCount = 0;
|
||||
startAcarsMainSSE();
|
||||
} else {
|
||||
alert(data.message || 'Failed to start ACARS');
|
||||
}
|
||||
})
|
||||
.catch(err => alert('Error: ' + err.message));
|
||||
}
|
||||
|
||||
function stopAcarsMode() {
|
||||
fetch('/acars/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
document.getElementById('startAcarsBtn').style.display = 'block';
|
||||
document.getElementById('stopAcarsBtn').style.display = 'none';
|
||||
document.getElementById('acarsStatusText').textContent = 'Standby';
|
||||
document.getElementById('acarsStatusText').style.color = 'var(--accent-yellow)';
|
||||
if (acarsMainEventSource) {
|
||||
acarsMainEventSource.close();
|
||||
acarsMainEventSource = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function acarsMainTypeBadge(type) {
|
||||
const colors = {
|
||||
position: '#00ff88', engine_data: '#ff9500', weather: '#00d4ff',
|
||||
ats: '#ffdd00', cpdlc: '#b388ff', oooi: '#4fc3f7', squawk: '#ff6b6b',
|
||||
link_test: '#666', handshake: '#555', other: '#888'
|
||||
};
|
||||
const labels = {
|
||||
position: 'POS', engine_data: 'ENG', weather: 'WX', ats: 'ATS',
|
||||
cpdlc: 'CPDLC', oooi: 'OOOI', squawk: 'SQK', link_test: 'LINK',
|
||||
handshake: 'HSHK', other: 'MSG'
|
||||
};
|
||||
const color = colors[type] || '#888';
|
||||
const lbl = labels[type] || 'MSG';
|
||||
return `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:8px;font-weight:700;color:#000;background:${color};">${lbl}</span>`;
|
||||
}
|
||||
|
||||
function renderAcarsMainCard(data) {
|
||||
const flight = escapeHtml(data.flight || 'UNKNOWN');
|
||||
const tail = escapeHtml(data.tail || data.reg || '');
|
||||
const type = data.message_type || 'other';
|
||||
const badge = acarsMainTypeBadge(type);
|
||||
const desc = escapeHtml(data.label_description || (data.label ? 'Label: ' + data.label : ''));
|
||||
const text = data.text || data.msg || '';
|
||||
const truncText = escapeHtml(text.length > 200 ? text.substring(0, 200) + '...' : text);
|
||||
const time = typeof InterceptTime !== 'undefined'
|
||||
? InterceptTime.shortTime(new Date()) + InterceptTime.tzSuffix()
|
||||
: new Date().toLocaleTimeString();
|
||||
|
||||
let parsedHtml = '';
|
||||
if (data.parsed) {
|
||||
const p = data.parsed;
|
||||
if (type === 'position' && p.lat !== undefined) {
|
||||
parsedHtml = `<div style="color:var(--accent-green);margin-top:2px;font-size:10px;">${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}${p.flight_level ? ' • FL' + escapeHtml(String(p.flight_level)) : ''}${p.destination ? ' → ' + escapeHtml(String(p.destination)) : ''}</div>`;
|
||||
} else if (type === 'engine_data') {
|
||||
const parts = [];
|
||||
Object.keys(p).forEach(k => {
|
||||
const val = typeof p[k] === 'object' ? p[k].value : p[k];
|
||||
parts.push(escapeHtml(k) + ': ' + escapeHtml(String(val)));
|
||||
});
|
||||
if (parts.length) parsedHtml = `<div style="color:#ff9500;margin-top:2px;font-size:10px;">${parts.slice(0, 4).join(' | ')}</div>`;
|
||||
} else if (type === 'oooi' && p.origin) {
|
||||
parsedHtml = `<div style="color:var(--accent-cyan);margin-top:2px;font-size:10px;">${escapeHtml(String(p.origin))} → ${escapeHtml(String(p.destination))}${p.out ? ' | OUT ' + escapeHtml(String(p.out)) : ''}${p.off ? ' OFF ' + escapeHtml(String(p.off)) : ''}${p.on ? ' ON ' + escapeHtml(String(p.on)) : ''}${p['in'] ? ' IN ' + escapeHtml(String(p['in'])) : ''}</div>`;
|
||||
} else if (type === 'weather' && (p.wind_speed || p.temperature)) {
|
||||
const wx = [];
|
||||
if (p.wind_speed) wx.push('Wind ' + escapeHtml(String(p.wind_speed)) + (p.wind_dir ? '/' + escapeHtml(String(p.wind_dir)) : ''));
|
||||
if (p.temperature) wx.push(escapeHtml(String(p.temperature)) + '°C');
|
||||
if (p.turbulence) wx.push('Turb: ' + escapeHtml(String(p.turbulence)));
|
||||
if (wx.length) parsedHtml = `<div style="color:#00d4ff;margin-top:2px;font-size:10px;">${wx.join(' | ')}</div>`;
|
||||
} else if (type === 'cpdlc') {
|
||||
const cpdlcText = p.message || p.text || '';
|
||||
if (cpdlcText) parsedHtml = `<div style="color:#b388ff;margin-top:2px;font-size:10px;font-weight:600;">${escapeHtml(String(cpdlcText))}</div>`;
|
||||
} else if (type === 'squawk' && p.squawk) {
|
||||
parsedHtml = `<div style="color:#ff6b6b;margin-top:2px;font-size:10px;font-weight:600;">Squawk: ${escapeHtml(String(p.squawk))}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `<div class="acars-feed-card" style="padding:6px 8px;border-bottom:1px solid var(--border-color);animation:fadeInMsg 0.3s ease;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px;">
|
||||
<span style="color:var(--accent-cyan);font-weight:bold;">${flight}${tail ? ' <span style="color:var(--text-muted);font-weight:normal;font-size:9px;">(' + tail + ')</span>' : ''}</span>
|
||||
<span style="color:var(--text-muted);font-size:9px;">${time}</span>
|
||||
</div>
|
||||
<div style="margin-top:2px;">${badge} <span style="color:var(--text-primary);">${desc}</span></div>
|
||||
${parsedHtml}
|
||||
${truncText && type !== 'link_test' && type !== 'handshake' ? `<div style="color:var(--text-dim);font-family:var(--font-mono);font-size:9px;margin-top:3px;word-break:break-all;">${truncText}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function startAcarsMainSSE() {
|
||||
if (acarsMainEventSource) acarsMainEventSource.close();
|
||||
|
||||
const feed = document.getElementById('acarsMessageFeed');
|
||||
if (feed && feed.querySelector('[style*="font-style: italic"]')) {
|
||||
feed.innerHTML = '';
|
||||
}
|
||||
|
||||
acarsMainEventSource = new EventSource('/acars/stream');
|
||||
acarsMainEventSource.onmessage = function(e) {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'acars') {
|
||||
acarsMainMsgCount++;
|
||||
document.getElementById('acarsMessageCount').textContent = acarsMainMsgCount;
|
||||
|
||||
// Add to message feed
|
||||
const feed = document.getElementById('acarsMessageFeed');
|
||||
if (feed) {
|
||||
feed.insertAdjacentHTML('afterbegin', renderAcarsMainCard(data));
|
||||
// Keep max 30 messages for RPi performance
|
||||
while (feed.children.length > 30) {
|
||||
feed.removeChild(feed.lastChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
acarsMainEventSource.onerror = function() {
|
||||
setTimeout(() => {
|
||||
const panel = document.getElementById('acarsMode');
|
||||
if (panel && panel.classList.contains('active') &&
|
||||
document.getElementById('stopAcarsBtn').style.display === 'block') {
|
||||
startAcarsMainSSE();
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
// Check initial status
|
||||
fetch('/acars/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.running) {
|
||||
document.getElementById('startAcarsBtn').style.display = 'none';
|
||||
document.getElementById('stopAcarsBtn').style.display = 'block';
|
||||
document.getElementById('acarsStatusText').textContent = 'Listening';
|
||||
document.getElementById('acarsStatusText').style.color = 'var(--accent-green)';
|
||||
document.getElementById('acarsMessageCount').textContent = data.message_count || 0;
|
||||
acarsMainMsgCount = data.message_count || 0;
|
||||
startAcarsMainSSE();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Initialize frequency checkboxes
|
||||
document.addEventListener('DOMContentLoaded', () => updateAcarsMainFreqs());
|
||||
</script>
|
||||
@@ -1,218 +0,0 @@
|
||||
<!-- AIS VESSEL TRACKING MODE -->
|
||||
<div id="aisMode" class="mode-content" style="display: none;">
|
||||
<div class="section">
|
||||
<h3>AIS Vessel Tracking</h3>
|
||||
<div class="info-text" style="margin-bottom: 15px;">
|
||||
Track ships and vessels via AIS (Automatic Identification System) on 161.975 / 162.025 MHz.
|
||||
</div>
|
||||
<a href="/ais/dashboard" target="_blank" class="run-btn" style="display: inline-block; text-decoration: none; text-align: center; margin-bottom: 15px;">
|
||||
Open AIS Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Settings</h3>
|
||||
<div class="form-group">
|
||||
<label>Gain (dB, 0 = auto)</label>
|
||||
<input type="number" id="aisGainInput" value="40" min="0" max="50" placeholder="0-50">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>NMEA UDP Forward</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
|
||||
Forward NMEA 0183 sentences to an external app (e.g. OpenCPN). Leave host blank to disable.
|
||||
</p>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<div style="flex: 2;">
|
||||
<label style="font-size: 10px; color: var(--text-dim);">Host</label>
|
||||
<input type="text" id="aisUdpHost" placeholder="e.g. 192.168.1.10" style="width: 100%;">
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<label style="font-size: 10px; color: var(--text-dim);">Port</label>
|
||||
<input type="number" id="aisUdpPort" value="10110" min="1" max="65535" style="width: 100%;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Status</h3>
|
||||
<div id="aisStatusDisplay" class="info-text">
|
||||
<p>Status: <span id="aisStatusText" style="color: var(--accent-yellow);">Standby</span></p>
|
||||
<p>Vessels: <span id="aisVesselCount">0</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide -->
|
||||
<div class="section">
|
||||
<h3>Antenna Guide</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||
Marine VHF band (162 MHz) — stock SDR antenna will NOT work well
|
||||
</p>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole (Cheapest)</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Element length:</strong> ~46 cm each (quarter-wave at 162 MHz)</li>
|
||||
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
|
||||
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (AIS is vertically polarized)</li>
|
||||
<li><strong style="color: var(--text-primary);">Placement:</strong> As high as possible with clear view of the water/harbor</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Commercial Options</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Marine VHF whip:</strong> ~$20–50, designed for 156–163 MHz band</li>
|
||||
<li><strong style="color: var(--text-primary);">Discone:</strong> ~$30–50, wideband coverage including marine VHF</li>
|
||||
<li><strong style="color: var(--text-primary);">Collinear:</strong> Higher gain (~6 dBi), best for coastal monitoring</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Placement Tips</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Height is critical:</strong> AIS is line-of-sight. Roof or mast mount is ideal</li>
|
||||
<li><strong style="color: var(--text-primary);">Range:</strong> At 10m height, expect ~25 NM (46 km) range over water</li>
|
||||
<li><strong style="color: var(--text-primary);">LNA:</strong> Nooelec Lana or similar broadband LNA, mount at antenna</li>
|
||||
<li><strong style="color: var(--text-primary);">Coax:</strong> Keep cable short. RG-58 loses ~4 dB per 10m at 162 MHz</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||
<strong style="color: var(--accent-cyan); 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);">AIS Channel A</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">161.975 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">AIS Channel B</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">162.025 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">46 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">GMSK 9600 baud</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Bandwidth</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">25 kHz</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="startAisBtn" onclick="startAisTracking()">
|
||||
Start AIS Tracking
|
||||
</button>
|
||||
<button class="stop-btn" id="stopAisBtn" onclick="stopAisTracking()" style="display: none;">
|
||||
Stop AIS Tracking
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let aisEventSource = null;
|
||||
let aisVessels = {};
|
||||
|
||||
function startAisTracking() {
|
||||
const gain = document.getElementById('aisGainInput').value || '40';
|
||||
const device = document.getElementById('deviceSelect')?.value || '0';
|
||||
const udpHost = document.getElementById('aisUdpHost').value.trim();
|
||||
const udpPort = parseInt(document.getElementById('aisUdpPort').value) || 10110;
|
||||
|
||||
const body = {
|
||||
device, gain,
|
||||
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
|
||||
};
|
||||
if (udpHost) {
|
||||
body.udp_host = udpHost;
|
||||
body.udp_port = udpPort;
|
||||
}
|
||||
|
||||
fetch('/ais/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'started' || data.status === 'already_running') {
|
||||
document.getElementById('startAisBtn').style.display = 'none';
|
||||
document.getElementById('stopAisBtn').style.display = 'block';
|
||||
document.getElementById('aisStatusText').textContent = 'Tracking';
|
||||
document.getElementById('aisStatusText').style.color = 'var(--accent-green)';
|
||||
startAisSSE();
|
||||
} else {
|
||||
alert(data.message || 'Failed to start AIS tracking');
|
||||
}
|
||||
})
|
||||
.catch(err => alert('Error: ' + err.message));
|
||||
}
|
||||
|
||||
function stopAisTracking() {
|
||||
fetch('/ais/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
document.getElementById('startAisBtn').style.display = 'block';
|
||||
document.getElementById('stopAisBtn').style.display = 'none';
|
||||
document.getElementById('aisStatusText').textContent = 'Standby';
|
||||
document.getElementById('aisStatusText').style.color = 'var(--accent-yellow)';
|
||||
document.getElementById('aisVesselCount').textContent = '0';
|
||||
if (aisEventSource) {
|
||||
aisEventSource.close();
|
||||
aisEventSource = null;
|
||||
}
|
||||
aisVessels = {};
|
||||
});
|
||||
}
|
||||
|
||||
function startAisSSE() {
|
||||
if (aisEventSource) aisEventSource.close();
|
||||
|
||||
aisEventSource = new EventSource('/ais/stream');
|
||||
aisEventSource.onmessage = function(e) {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'vessel') {
|
||||
aisVessels[data.mmsi] = data;
|
||||
document.getElementById('aisVesselCount').textContent = Object.keys(aisVessels).length;
|
||||
}
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
aisEventSource.onerror = function() {
|
||||
setTimeout(() => {
|
||||
const panel = document.getElementById('aisMode');
|
||||
if (panel && panel.classList.contains('active') &&
|
||||
document.getElementById('stopAisBtn').style.display === 'block') {
|
||||
startAisSSE();
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
// Check initial status
|
||||
fetch('/ais/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.tracking_active) {
|
||||
document.getElementById('startAisBtn').style.display = 'none';
|
||||
document.getElementById('stopAisBtn').style.display = 'block';
|
||||
document.getElementById('aisStatusText').textContent = 'Tracking';
|
||||
document.getElementById('aisStatusText').style.color = 'var(--accent-green)';
|
||||
document.getElementById('aisVesselCount').textContent = data.vessel_count || 0;
|
||||
startAisSSE();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
</script>
|
||||
@@ -1,242 +0,0 @@
|
||||
<!-- VDL2 AIRCRAFT DATALINK MODE -->
|
||||
<div id="vdl2Mode" class="mode-content" style="display: none;">
|
||||
<div class="section">
|
||||
<h3>VDL2 Datalink</h3>
|
||||
<div class="info-text" style="margin-bottom: 15px;">
|
||||
Decode VDL Mode 2 (VHF Digital Link) messages on ~136 MHz. VDL2 is the digital successor to ACARS, using D8PSK modulation for higher throughput aircraft datalink communications.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Region & Frequencies</h3>
|
||||
<div class="form-group">
|
||||
<label>Region</label>
|
||||
<select id="vdl2RegionSelect" onchange="updateVdl2MainFreqs()" style="width: 100%;">
|
||||
<option value="na">North America</option>
|
||||
<option value="eu">Europe</option>
|
||||
<option value="ap">Asia-Pacific</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="vdl2MainFreqSelector" style="display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; font-size: 11px;">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Gain (dB, 0 = auto)</label>
|
||||
<input type="number" id="vdl2GainInput" value="40" min="0" max="50" placeholder="0-50">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Status</h3>
|
||||
<div id="vdl2StatusDisplay" class="info-text">
|
||||
<p>Status: <span id="vdl2StatusText" style="color: var(--accent-yellow);">Standby</span></p>
|
||||
<p>Messages: <span id="vdl2MessageCount">0</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide -->
|
||||
<div class="section">
|
||||
<h3>Antenna Guide</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||
VHF Airband (~137 MHz) — stock SDR antenna may work at close range
|
||||
</p>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Element length:</strong> ~55 cm each (quarter-wave at 137 MHz)</li>
|
||||
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
|
||||
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (airband is vertically polarized)</li>
|
||||
<li><strong style="color: var(--text-primary);">Placement:</strong> Outdoors, as high as possible with clear sky view</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||
<strong style="color: var(--accent-cyan); 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);">Primary (worldwide)</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">136.975 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">55 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">D8PSK 31.5 kbps</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Bandwidth</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">25 kHz</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="startVdl2Btn" onclick="startVdl2Mode()">
|
||||
Start VDL2
|
||||
</button>
|
||||
<button class="stop-btn" id="stopVdl2Btn" onclick="stopVdl2Mode()" style="display: none;">
|
||||
Stop VDL2
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let vdl2MainEventSource = null;
|
||||
let vdl2MainMsgCount = 0;
|
||||
|
||||
// VDL2 frequencies in Hz (as required by dumpvdl2)
|
||||
const vdl2MainFrequencies = {
|
||||
'na': ['136975000', '136100000', '136650000', '136700000', '136800000'],
|
||||
'eu': ['136975000', '136675000', '136725000', '136775000', '136825000'],
|
||||
'ap': ['136975000', '136900000']
|
||||
};
|
||||
|
||||
// Display-friendly MHz labels
|
||||
const vdl2FreqLabels = {
|
||||
'136975000': '136.975',
|
||||
'136100000': '136.100',
|
||||
'136650000': '136.650',
|
||||
'136700000': '136.700',
|
||||
'136800000': '136.800',
|
||||
'136675000': '136.675',
|
||||
'136725000': '136.725',
|
||||
'136775000': '136.775',
|
||||
'136825000': '136.825',
|
||||
'136900000': '136.900'
|
||||
};
|
||||
|
||||
function updateVdl2MainFreqs() {
|
||||
const region = document.getElementById('vdl2RegionSelect').value;
|
||||
const freqs = vdl2MainFrequencies[region] || vdl2MainFrequencies['na'];
|
||||
const container = document.getElementById('vdl2MainFreqSelector');
|
||||
|
||||
const previouslyChecked = new Set();
|
||||
container.querySelectorAll('input:checked').forEach(cb => previouslyChecked.add(cb.value));
|
||||
|
||||
container.innerHTML = freqs.map(freq => {
|
||||
const checked = previouslyChecked.size === 0 || previouslyChecked.has(freq) ? 'checked' : '';
|
||||
const label = vdl2FreqLabels[freq] || freq;
|
||||
return `
|
||||
<label style="display: flex; align-items: center; gap: 3px; padding: 2px 6px; background: var(--bg-secondary); border-radius: 3px; cursor: pointer;">
|
||||
<input type="checkbox" class="vdl2-main-freq-cb" value="${freq}" ${checked} style="margin: 0; cursor: pointer;">
|
||||
<span>${label}</span>
|
||||
</label>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getVdl2MainSelectedFreqs() {
|
||||
const checkboxes = document.querySelectorAll('.vdl2-main-freq-cb:checked');
|
||||
const selected = Array.from(checkboxes).map(cb => cb.value);
|
||||
if (selected.length === 0) {
|
||||
const region = document.getElementById('vdl2RegionSelect').value;
|
||||
return vdl2MainFrequencies[region] || vdl2MainFrequencies['na'];
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function startVdl2Mode() {
|
||||
const gain = document.getElementById('vdl2GainInput').value || '40';
|
||||
const device = document.getElementById('deviceSelect')?.value || '0';
|
||||
const frequencies = getVdl2MainSelectedFreqs();
|
||||
|
||||
fetch('/vdl2/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device, gain, frequencies })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'started') {
|
||||
document.getElementById('startVdl2Btn').style.display = 'none';
|
||||
document.getElementById('stopVdl2Btn').style.display = 'block';
|
||||
document.getElementById('vdl2StatusText').textContent = 'Listening';
|
||||
document.getElementById('vdl2StatusText').style.color = 'var(--accent-green)';
|
||||
vdl2MainMsgCount = 0;
|
||||
startVdl2MainSSE();
|
||||
} else {
|
||||
alert(data.message || 'Failed to start VDL2');
|
||||
}
|
||||
})
|
||||
.catch(err => alert('Error: ' + err.message));
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
vdl2MainEventSource = new EventSource('/vdl2/stream');
|
||||
vdl2MainEventSource.onmessage = function(e) {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'vdl2') {
|
||||
vdl2MainMsgCount++;
|
||||
document.getElementById('vdl2MessageCount').textContent = vdl2MainMsgCount;
|
||||
}
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
vdl2MainEventSource.onerror = function() {
|
||||
setTimeout(() => {
|
||||
const panel = document.getElementById('vdl2Mode');
|
||||
if (panel && panel.classList.contains('active') &&
|
||||
document.getElementById('stopVdl2Btn').style.display === 'block') {
|
||||
startVdl2MainSSE();
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
// Check initial status
|
||||
fetch('/vdl2/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.running) {
|
||||
document.getElementById('startVdl2Btn').style.display = 'none';
|
||||
document.getElementById('stopVdl2Btn').style.display = 'block';
|
||||
document.getElementById('vdl2StatusText').textContent = 'Listening';
|
||||
document.getElementById('vdl2StatusText').style.color = 'var(--accent-green)';
|
||||
document.getElementById('vdl2MessageCount').textContent = data.message_count || 0;
|
||||
vdl2MainMsgCount = data.message_count || 0;
|
||||
startVdl2MainSSE();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Initialize frequency checkboxes
|
||||
document.addEventListener('DOMContentLoaded', () => updateVdl2MainFreqs());
|
||||
</script>
|
||||
+70
-25
@@ -1,23 +1,30 @@
|
||||
"""Pytest configuration and fixtures."""
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import sqlite3
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Must be set before importing app: stops the deferred background-init
|
||||
# thread, whose subprocess/DB cleanup fires mid-session and races with
|
||||
# test mocks (e.g. a patched subprocess.Popen catching its pkill call)
|
||||
os.environ.setdefault("INTERCEPT_SKIP_DEFERRED_INIT", "1")
|
||||
|
||||
from app import app as flask_app
|
||||
from routes import register_blueprints
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
@pytest.fixture(scope="session")
|
||||
def app():
|
||||
"""Create application for testing."""
|
||||
flask_app.config['TESTING'] = True
|
||||
os.environ["INTERCEPT_DISABLE_AUTH"] = "1"
|
||||
flask_app.config["TESTING"] = True
|
||||
# Disable CSRF for tests
|
||||
flask_app.config['WTF_CSRF_ENABLED'] = False
|
||||
flask_app.config["WTF_CSRF_ENABLED"] = False
|
||||
# Register blueprints only if not already registered
|
||||
if 'pager' not in flask_app.blueprints:
|
||||
if "pager" not in flask_app.blueprints:
|
||||
register_blueprints(flask_app)
|
||||
return flask_app
|
||||
|
||||
@@ -37,8 +44,7 @@ def mock_subprocess():
|
||||
mock_subprocess['run'].return_value.stdout = 'output'
|
||||
mock_subprocess['run'].return_value.returncode = 0
|
||||
"""
|
||||
with patch('subprocess.Popen') as mock_popen, \
|
||||
patch('subprocess.run') as mock_run:
|
||||
with patch("subprocess.Popen") as mock_popen, patch("subprocess.run") as mock_run:
|
||||
mock_process = MagicMock()
|
||||
mock_process.poll.return_value = None
|
||||
mock_process.stdout = MagicMock()
|
||||
@@ -46,14 +52,12 @@ def mock_subprocess():
|
||||
mock_process.pid = 12345
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0, stdout='', stderr=''
|
||||
)
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
yield {
|
||||
'popen': mock_popen,
|
||||
'process': mock_process,
|
||||
'run': mock_run,
|
||||
"popen": mock_popen,
|
||||
"process": mock_process,
|
||||
"run": mock_run,
|
||||
}
|
||||
|
||||
|
||||
@@ -65,14 +69,16 @@ def mock_sdr_device():
|
||||
def test_example(mock_sdr_device):
|
||||
device = mock_sdr_device(device_type='rtlsdr', index=0)
|
||||
"""
|
||||
def _factory(device_type='rtlsdr', index=0):
|
||||
|
||||
def _factory(device_type="rtlsdr", index=0):
|
||||
device = MagicMock()
|
||||
device.device_type = device_type
|
||||
device.device_index = index
|
||||
device.name = f'Mock {device_type} #{index}'
|
||||
device.name = f"Mock {device_type} #{index}"
|
||||
device.is_available.return_value = True
|
||||
device.build_command.return_value = ['rtl_fm', '-f', '100M']
|
||||
device.build_command.return_value = ["rtl_fm", "-f", "100M"]
|
||||
return device
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
@@ -92,9 +98,9 @@ def mock_app_state():
|
||||
mock_lock = MagicMock()
|
||||
|
||||
patches = {
|
||||
'current_process': mock_process,
|
||||
'pager_queue': mock_queue,
|
||||
'pager_lock': mock_lock,
|
||||
"current_process": mock_process,
|
||||
"pager_queue": mock_queue,
|
||||
"pager_lock": mock_lock,
|
||||
}
|
||||
originals = {}
|
||||
for attr, value in patches.items():
|
||||
@@ -102,10 +108,10 @@ def mock_app_state():
|
||||
setattr(app_module, attr, value)
|
||||
|
||||
yield {
|
||||
'process': mock_process,
|
||||
'queue': mock_queue,
|
||||
'lock': mock_lock,
|
||||
'module': app_module,
|
||||
"process": mock_process,
|
||||
"queue": mock_queue,
|
||||
"lock": mock_lock,
|
||||
"module": app_module,
|
||||
}
|
||||
|
||||
for attr, orig in originals.items():
|
||||
@@ -119,16 +125,55 @@ def mock_app_state():
|
||||
@pytest.fixture
|
||||
def mock_check_tool():
|
||||
"""Patch check_tool() to return True for all tools."""
|
||||
with patch('utils.dependencies.check_tool', return_value=True) as mock:
|
||||
with patch("utils.dependencies.check_tool", return_value=True) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_db(tmp_path):
|
||||
"""Provide an isolated in-memory SQLite database for tests."""
|
||||
db_path = tmp_path / 'test.db'
|
||||
db_path = tmp_path / "test.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute('PRAGMA journal_mode = WAL')
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_tle_store(tmp_path, monkeypatch):
|
||||
"""Every test gets a throwaway TLE store; nothing touches instance/tle.db."""
|
||||
from utils import tle_store
|
||||
|
||||
monkeypatch.setattr(tle_store, "_DB_PATH", tmp_path / "tle.db")
|
||||
tle_store._reset_for_tests()
|
||||
yield
|
||||
tle_store._reset_for_tests()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_process():
|
||||
"""Factory for complete subprocess.Popen replacements.
|
||||
|
||||
Hand-rolled Popen mocks keep missing two things: __enter__ (subprocess.run
|
||||
wraps Popen in a context manager) and a communicate() tuple. Use this
|
||||
factory instead of building MagicMock processes inline.
|
||||
|
||||
Defaults are str (for text=True subprocesses); pass bytes explicitly for binary-mode callers.
|
||||
"""
|
||||
|
||||
def _make(returncode=0, stdout="", stderr="", running=True, pid=12345):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = None if running else returncode
|
||||
proc.returncode = returncode
|
||||
proc.pid = pid
|
||||
proc.wait.return_value = returncode
|
||||
proc.communicate.return_value = (stdout, stderr)
|
||||
proc.stdout.read.return_value = stdout
|
||||
proc.stderr.read.return_value = stderr
|
||||
proc.stdin = MagicMock()
|
||||
proc.__enter__.return_value = proc
|
||||
proc.__exit__.return_value = False
|
||||
return proc
|
||||
|
||||
return _make
|
||||
|
||||
+135
-148
@@ -25,10 +25,12 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
# Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mode_manager():
|
||||
"""Create a fresh ModeManager instance for testing."""
|
||||
from intercept_agent import ModeManager
|
||||
|
||||
manager = ModeManager()
|
||||
yield manager
|
||||
# Cleanup: stop all modes
|
||||
@@ -38,17 +40,10 @@ def mode_manager():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_subprocess():
|
||||
def mock_subprocess(fake_process):
|
||||
"""Mock subprocess.Popen for controlled testing."""
|
||||
with patch('subprocess.Popen') as mock_popen:
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None # Process is running
|
||||
mock_proc.stdout = MagicMock()
|
||||
mock_proc.stderr = MagicMock()
|
||||
mock_proc.stderr.read.return_value = b''
|
||||
mock_proc.stdin = MagicMock()
|
||||
mock_proc.pid = 12345
|
||||
mock_proc.wait.return_value = 0
|
||||
with patch("subprocess.Popen") as mock_popen:
|
||||
mock_proc = fake_process()
|
||||
mock_popen.return_value = mock_proc
|
||||
yield mock_popen, mock_proc
|
||||
|
||||
@@ -57,19 +52,19 @@ def mock_subprocess():
|
||||
def mock_tools():
|
||||
"""Mock tool availability checks."""
|
||||
tools = {
|
||||
'rtl_433': '/usr/bin/rtl_433',
|
||||
'rtl_fm': '/usr/bin/rtl_fm',
|
||||
'dump1090': '/usr/bin/dump1090',
|
||||
'multimon-ng': '/usr/bin/multimon-ng',
|
||||
'airodump-ng': '/usr/sbin/airodump-ng',
|
||||
'acarsdec': '/usr/bin/acarsdec',
|
||||
'AIS-catcher': '/usr/bin/AIS-catcher',
|
||||
'direwolf': '/usr/bin/direwolf',
|
||||
'rtlamr': '/usr/bin/rtlamr',
|
||||
'rtl_tcp': '/usr/bin/rtl_tcp',
|
||||
'bluetoothctl': '/usr/bin/bluetoothctl',
|
||||
"rtl_433": "/usr/bin/rtl_433",
|
||||
"rtl_fm": "/usr/bin/rtl_fm",
|
||||
"dump1090": "/usr/bin/dump1090",
|
||||
"multimon-ng": "/usr/bin/multimon-ng",
|
||||
"airodump-ng": "/usr/sbin/airodump-ng",
|
||||
"acarsdec": "/usr/bin/acarsdec",
|
||||
"AIS-catcher": "/usr/bin/AIS-catcher",
|
||||
"direwolf": "/usr/bin/direwolf",
|
||||
"rtlamr": "/usr/bin/rtlamr",
|
||||
"rtl_tcp": "/usr/bin/rtl_tcp",
|
||||
"bluetoothctl": "/usr/bin/bluetoothctl",
|
||||
}
|
||||
with patch('shutil.which', side_effect=lambda x: tools.get(x)):
|
||||
with patch("shutil.which", side_effect=lambda x: tools.get(x)):
|
||||
yield tools
|
||||
|
||||
|
||||
@@ -77,8 +72,8 @@ def mock_tools():
|
||||
# SDR Mode List
|
||||
# =============================================================================
|
||||
|
||||
SDR_MODES = ['sensor', 'adsb', 'pager', 'ais', 'acars', 'aprs', 'rtlamr', 'dsc', 'listening_post']
|
||||
NON_SDR_MODES = ['wifi', 'bluetooth', 'tscm', 'satellite']
|
||||
SDR_MODES = ["sensor", "adsb", "pager", "ais", "acars", "aprs", "rtlamr", "dsc", "listening_post"]
|
||||
NON_SDR_MODES = ["wifi", "bluetooth", "tscm", "satellite"]
|
||||
ALL_MODES = SDR_MODES + NON_SDR_MODES
|
||||
|
||||
|
||||
@@ -86,6 +81,7 @@ ALL_MODES = SDR_MODES + NON_SDR_MODES
|
||||
# Mode Lifecycle Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestModeLifecycle:
|
||||
"""Test start/stop lifecycle for all modes."""
|
||||
|
||||
@@ -94,99 +90,88 @@ class TestModeLifecycle:
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
|
||||
# Start
|
||||
result = mode_manager.start_mode('sensor', {'frequency': '433.92', 'device': '0'})
|
||||
assert result['status'] == 'started'
|
||||
assert 'sensor' in mode_manager.running_modes
|
||||
result = mode_manager.start_mode("sensor", {"frequency": "433.92", "device": "0"})
|
||||
assert result["status"] == "started"
|
||||
assert "sensor" in mode_manager.running_modes
|
||||
|
||||
# Stop
|
||||
result = mode_manager.stop_mode('sensor')
|
||||
assert result['status'] == 'stopped'
|
||||
assert 'sensor' not in mode_manager.running_modes
|
||||
result = mode_manager.stop_mode("sensor")
|
||||
assert result["status"] == "stopped"
|
||||
assert "sensor" not in mode_manager.running_modes
|
||||
|
||||
def test_adsb_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
||||
"""ADS-B mode should start and stop cleanly."""
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
|
||||
# Mock socket for SBS connection check
|
||||
with patch('socket.socket') as mock_socket:
|
||||
with patch("socket.socket") as mock_socket:
|
||||
mock_sock = MagicMock()
|
||||
mock_sock.connect_ex.return_value = 1 # Port not in use
|
||||
mock_socket.return_value = mock_sock
|
||||
|
||||
result = mode_manager.start_mode('adsb', {'device': '0', 'gain': '40'})
|
||||
result = mode_manager.start_mode("adsb", {"device": "0", "gain": "40"})
|
||||
# May fail due to SBS port check, but shouldn't crash
|
||||
assert result['status'] in ['started', 'error']
|
||||
assert result["status"] in ["started", "error"]
|
||||
|
||||
def test_pager_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
||||
"""Pager mode should start and stop cleanly."""
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
|
||||
result = mode_manager.start_mode('pager', {
|
||||
'frequency': '929.6125',
|
||||
'protocols': ['POCSAG512', 'POCSAG1200']
|
||||
})
|
||||
assert result['status'] == 'started'
|
||||
assert 'pager' in mode_manager.running_modes
|
||||
result = mode_manager.start_mode("pager", {"frequency": "929.6125", "protocols": ["POCSAG512", "POCSAG1200"]})
|
||||
assert result["status"] == "started"
|
||||
assert "pager" in mode_manager.running_modes
|
||||
|
||||
result = mode_manager.stop_mode('pager')
|
||||
assert result['status'] == 'stopped'
|
||||
result = mode_manager.stop_mode("pager")
|
||||
assert result["status"] == "stopped"
|
||||
|
||||
def test_wifi_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
||||
"""WiFi mode should start and stop cleanly."""
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
|
||||
# Mock glob for CSV file detection
|
||||
with patch('glob.glob', return_value=[]), patch('tempfile.mkdtemp', return_value='/tmp/test'):
|
||||
result = mode_manager.start_mode('wifi', {
|
||||
'interface': 'wlan0',
|
||||
'scan_type': 'quick'
|
||||
})
|
||||
with patch("glob.glob", return_value=[]), patch("tempfile.mkdtemp", return_value="/tmp/test"):
|
||||
result = mode_manager.start_mode("wifi", {"interface": "wlan0", "scan_type": "quick"})
|
||||
# Quick scan returns data directly
|
||||
assert result['status'] in ['started', 'error', 'success']
|
||||
assert result["status"] in ["started", "error", "success"]
|
||||
|
||||
def test_bluetooth_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
||||
"""Bluetooth mode should start and stop cleanly."""
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
|
||||
result = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
|
||||
assert result['status'] == 'started'
|
||||
assert 'bluetooth' in mode_manager.running_modes
|
||||
result = mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
|
||||
assert result["status"] == "started"
|
||||
assert "bluetooth" in mode_manager.running_modes
|
||||
|
||||
# Give thread time to start
|
||||
time.sleep(0.1)
|
||||
|
||||
result = mode_manager.stop_mode('bluetooth')
|
||||
assert result['status'] == 'stopped'
|
||||
result = mode_manager.stop_mode("bluetooth")
|
||||
assert result["status"] == "stopped"
|
||||
|
||||
def test_satellite_mode_lifecycle(self, mode_manager):
|
||||
"""Satellite mode should work without SDR."""
|
||||
# Satellite mode is computational only
|
||||
result = mode_manager.start_mode('satellite', {
|
||||
'lat': 33.5,
|
||||
'lon': -82.1,
|
||||
'min_elevation': 10
|
||||
})
|
||||
assert result['status'] in ['started', 'error'] # May fail if skyfield not installed
|
||||
# Patch the predictor loop — the real one downloads TLEs from
|
||||
# CelesTrak and keeps computing passes after the test finishes
|
||||
with patch.object(type(mode_manager), "_satellite_predictor", MagicMock()):
|
||||
result = mode_manager.start_mode("satellite", {"lat": 33.5, "lon": -82.1, "min_elevation": 10})
|
||||
assert result["status"] in ["started", "error"] # May fail if skyfield not installed
|
||||
|
||||
def test_tscm_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
||||
"""TSCM mode should start and stop cleanly."""
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
|
||||
result = mode_manager.start_mode('tscm', {
|
||||
'wifi': True,
|
||||
'bluetooth': True,
|
||||
'rf': False
|
||||
})
|
||||
assert result['status'] == 'started'
|
||||
result = mode_manager.start_mode("tscm", {"wifi": True, "bluetooth": True, "rf": False})
|
||||
assert result["status"] == "started"
|
||||
|
||||
result = mode_manager.stop_mode('tscm')
|
||||
assert result['status'] == 'stopped'
|
||||
result = mode_manager.stop_mode("tscm")
|
||||
assert result["status"] == "stopped"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SDR Conflict Detection Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestSDRConflictDetection:
|
||||
"""Test SDR device conflict detection."""
|
||||
|
||||
@@ -195,25 +180,25 @@ class TestSDRConflictDetection:
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
|
||||
# Start sensor on device 0
|
||||
result1 = mode_manager.start_mode('sensor', {'device': '0'})
|
||||
assert result1['status'] == 'started'
|
||||
result1 = mode_manager.start_mode("sensor", {"device": "0"})
|
||||
assert result1["status"] == "started"
|
||||
|
||||
# Try to start pager on device 0 - should fail
|
||||
result2 = mode_manager.start_mode('pager', {'device': '0'})
|
||||
assert result2['status'] == 'error'
|
||||
assert 'in use' in result2['message'].lower()
|
||||
result2 = mode_manager.start_mode("pager", {"device": "0"})
|
||||
assert result2["status"] == "error"
|
||||
assert "in use" in result2["message"].lower()
|
||||
|
||||
def test_different_device_no_conflict(self, mode_manager, mock_subprocess, mock_tools):
|
||||
"""Starting SDR modes on different devices should work."""
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
|
||||
# Start sensor on device 0
|
||||
result1 = mode_manager.start_mode('sensor', {'device': '0'})
|
||||
assert result1['status'] == 'started'
|
||||
result1 = mode_manager.start_mode("sensor", {"device": "0"})
|
||||
assert result1["status"] == "started"
|
||||
|
||||
# Start pager on device 1 - should work
|
||||
result2 = mode_manager.start_mode('pager', {'device': '1'})
|
||||
assert result2['status'] == 'started'
|
||||
result2 = mode_manager.start_mode("pager", {"device": "1"})
|
||||
assert result2["status"] == "started"
|
||||
|
||||
assert len(mode_manager.running_modes) == 2
|
||||
|
||||
@@ -222,12 +207,12 @@ class TestSDRConflictDetection:
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
|
||||
# Start sensor (SDR)
|
||||
result1 = mode_manager.start_mode('sensor', {'device': '0'})
|
||||
assert result1['status'] == 'started'
|
||||
result1 = mode_manager.start_mode("sensor", {"device": "0"})
|
||||
assert result1["status"] == "started"
|
||||
|
||||
# Start bluetooth (non-SDR) - should work
|
||||
result2 = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
|
||||
assert result2['status'] == 'started'
|
||||
result2 = mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
|
||||
assert result2["status"] == "started"
|
||||
|
||||
assert len(mode_manager.running_modes) == 2
|
||||
|
||||
@@ -239,10 +224,10 @@ class TestSDRConflictDetection:
|
||||
assert mode_manager.get_sdr_in_use(0) is None
|
||||
|
||||
# Start sensor
|
||||
mode_manager.start_mode('sensor', {'device': '0'})
|
||||
mode_manager.start_mode("sensor", {"device": "0"})
|
||||
|
||||
# Device 0 now in use by sensor
|
||||
assert mode_manager.get_sdr_in_use(0) == 'sensor'
|
||||
assert mode_manager.get_sdr_in_use(0) == "sensor"
|
||||
assert mode_manager.get_sdr_in_use(1) is None
|
||||
|
||||
|
||||
@@ -250,67 +235,63 @@ class TestSDRConflictDetection:
|
||||
# Process Verification Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestProcessVerification:
|
||||
"""Test process startup verification."""
|
||||
|
||||
def test_immediate_process_exit_detected(self, mode_manager, mock_tools):
|
||||
def test_immediate_process_exit_detected(self, mode_manager, mock_tools, fake_process):
|
||||
"""Process that exits immediately should return error."""
|
||||
with patch('subprocess.Popen') as mock_popen:
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1 # Process exited
|
||||
mock_proc.stderr.read.return_value = b'device busy'
|
||||
mock_popen.return_value = mock_proc
|
||||
with patch("subprocess.Popen") as mock_popen:
|
||||
mock_popen.return_value = fake_process(running=False, returncode=1, stderr=b"device busy")
|
||||
|
||||
result = mode_manager.start_mode('sensor', {'device': '0'})
|
||||
assert result['status'] == 'error'
|
||||
assert 'sensor' not in mode_manager.running_modes
|
||||
result = mode_manager.start_mode("sensor", {"device": "0"})
|
||||
assert result["status"] == "error"
|
||||
assert "sensor" not in mode_manager.running_modes
|
||||
|
||||
def test_running_process_accepted(self, mode_manager, mock_subprocess, mock_tools):
|
||||
"""Process that stays running should be accepted."""
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
mock_proc.poll.return_value = None # Still running
|
||||
|
||||
result = mode_manager.start_mode('sensor', {'device': '0'})
|
||||
assert result['status'] == 'started'
|
||||
assert 'sensor' in mode_manager.running_modes
|
||||
result = mode_manager.start_mode("sensor", {"device": "0"})
|
||||
assert result["status"] == "started"
|
||||
assert "sensor" in mode_manager.running_modes
|
||||
|
||||
def test_error_message_from_stderr(self, mode_manager, mock_tools):
|
||||
def test_error_message_from_stderr(self, mode_manager, mock_tools, fake_process):
|
||||
"""Error message should include stderr output."""
|
||||
with patch('subprocess.Popen') as mock_popen:
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.stderr.read.return_value = b'usb_claim_interface error -6'
|
||||
mock_popen.return_value = mock_proc
|
||||
with patch("subprocess.Popen") as mock_popen:
|
||||
mock_popen.return_value = fake_process(running=False, returncode=1, stderr=b"usb_claim_interface error -6")
|
||||
|
||||
result = mode_manager.start_mode('sensor', {'device': '0'})
|
||||
assert result['status'] == 'error'
|
||||
assert 'usb_claim_interface' in result['message'] or 'failed' in result['message'].lower()
|
||||
result = mode_manager.start_mode("sensor", {"device": "0"})
|
||||
assert result["status"] == "error"
|
||||
assert "usb_claim_interface" in result["message"] or "failed" in result["message"].lower()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Data Snapshot Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestDataSnapshots:
|
||||
"""Test data snapshot operations."""
|
||||
|
||||
def test_get_mode_data_empty(self, mode_manager):
|
||||
"""get_mode_data for non-running mode should return empty."""
|
||||
result = mode_manager.get_mode_data('sensor')
|
||||
assert result['mode'] == 'sensor'
|
||||
result = mode_manager.get_mode_data("sensor")
|
||||
assert result["mode"] == "sensor"
|
||||
# Mode not running - should have empty data or 'running' field
|
||||
assert result.get('running') is False or result.get('data') == [] or 'status' in result
|
||||
assert result.get("running") is False or result.get("data") == [] or "status" in result
|
||||
|
||||
def test_get_mode_data_running(self, mode_manager, mock_subprocess, mock_tools):
|
||||
"""get_mode_data for running mode should return status."""
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
|
||||
mode_manager.start_mode('sensor', {'device': '0'})
|
||||
result = mode_manager.get_mode_data('sensor')
|
||||
mode_manager.start_mode("sensor", {"device": "0"})
|
||||
result = mode_manager.get_mode_data("sensor")
|
||||
|
||||
assert result['mode'] == 'sensor'
|
||||
assert result["mode"] == "sensor"
|
||||
# Mode is running - should indicate running status
|
||||
assert result.get('running') is True or 'data' in result or 'status' in result
|
||||
assert result.get("running") is True or "data" in result or "status" in result
|
||||
|
||||
def test_data_queue_limit(self, mode_manager):
|
||||
"""Data queues should respect max size limits."""
|
||||
@@ -321,7 +302,7 @@ class TestDataSnapshots:
|
||||
for i in range(150):
|
||||
if test_queue.full():
|
||||
test_queue.get_nowait() # Remove old item
|
||||
test_queue.put_nowait({'index': i})
|
||||
test_queue.put_nowait({"index": i})
|
||||
|
||||
assert test_queue.qsize() <= 100
|
||||
|
||||
@@ -330,68 +311,73 @@ class TestDataSnapshots:
|
||||
# Mode Status Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestModeStatus:
|
||||
"""Test mode status reporting."""
|
||||
|
||||
def test_status_includes_all_modes(self, mode_manager):
|
||||
"""Status should include all running modes."""
|
||||
status = mode_manager.get_status()
|
||||
assert 'running_modes' in status
|
||||
assert 'running_modes_detail' in status
|
||||
assert isinstance(status['running_modes'], list)
|
||||
assert "running_modes" in status
|
||||
assert "running_modes_detail" in status
|
||||
assert isinstance(status["running_modes"], list)
|
||||
|
||||
def test_running_modes_detail_includes_device(self, mode_manager, mock_subprocess, mock_tools):
|
||||
"""Running modes detail should include device info."""
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
|
||||
mode_manager.start_mode('sensor', {'device': '0'})
|
||||
mode_manager.start_mode("sensor", {"device": "0"})
|
||||
status = mode_manager.get_status()
|
||||
|
||||
assert 'sensor' in status['running_modes_detail']
|
||||
detail = status['running_modes_detail']['sensor']
|
||||
assert 'device' in detail or 'params' in detail
|
||||
assert "sensor" in status["running_modes_detail"]
|
||||
detail = status["running_modes_detail"]["sensor"]
|
||||
assert "device" in detail or "params" in detail
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Error Handling Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test error handling scenarios."""
|
||||
|
||||
def test_missing_tool_returns_error(self, mode_manager):
|
||||
"""Mode should fail gracefully if required tool is missing."""
|
||||
with patch('shutil.which', return_value=None):
|
||||
result = mode_manager.start_mode('sensor', {'device': '0'})
|
||||
assert result['status'] == 'error'
|
||||
# get_tool_path checks Homebrew paths via os.path.isfile before
|
||||
# shutil.which, so patch it too or installed tools are still found
|
||||
with patch("utils.dependencies.get_tool_path", return_value=None), patch("shutil.which", return_value=None):
|
||||
result = mode_manager.start_mode("sensor", {"device": "0"})
|
||||
assert result["status"] == "error"
|
||||
# Error message may vary - check for common patterns
|
||||
msg = result['message'].lower()
|
||||
assert 'not found' in msg or 'not available' in msg or 'missing' in msg
|
||||
msg = result["message"].lower()
|
||||
assert "not found" in msg or "not available" in msg or "missing" in msg
|
||||
|
||||
def test_invalid_mode_returns_error(self, mode_manager):
|
||||
"""Invalid mode name should return error."""
|
||||
result = mode_manager.start_mode('invalid_mode', {})
|
||||
assert result['status'] == 'error'
|
||||
result = mode_manager.start_mode("invalid_mode", {})
|
||||
assert result["status"] == "error"
|
||||
|
||||
def test_double_start_returns_already_running(self, mode_manager, mock_subprocess, mock_tools):
|
||||
"""Starting already-running mode should return appropriate status."""
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
|
||||
mode_manager.start_mode('sensor', {'device': '0'})
|
||||
result = mode_manager.start_mode('sensor', {'device': '0'})
|
||||
mode_manager.start_mode("sensor", {"device": "0"})
|
||||
result = mode_manager.start_mode("sensor", {"device": "0"})
|
||||
|
||||
assert result['status'] in ['already_running', 'error']
|
||||
assert result["status"] in ["already_running", "error"]
|
||||
|
||||
def test_stop_non_running_mode(self, mode_manager):
|
||||
"""Stopping non-running mode should handle gracefully."""
|
||||
result = mode_manager.stop_mode('sensor')
|
||||
assert result['status'] in ['stopped', 'not_running']
|
||||
result = mode_manager.stop_mode("sensor")
|
||||
assert result["status"] in ["stopped", "not_running"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cleanup Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCleanup:
|
||||
"""Test mode cleanup on stop."""
|
||||
|
||||
@@ -399,8 +385,8 @@ class TestCleanup:
|
||||
"""Processes should be terminated when mode is stopped."""
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
|
||||
mode_manager.start_mode('sensor', {'device': '0'})
|
||||
mode_manager.stop_mode('sensor')
|
||||
mode_manager.start_mode("sensor", {"device": "0"})
|
||||
mode_manager.stop_mode("sensor")
|
||||
|
||||
# Verify terminate was called
|
||||
mock_proc.terminate.assert_called()
|
||||
@@ -409,20 +395,20 @@ class TestCleanup:
|
||||
"""Output threads should be stopped when mode is stopped."""
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
|
||||
mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
|
||||
mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
|
||||
time.sleep(0.1) # Let thread start
|
||||
|
||||
mode_manager.stop_mode('bluetooth')
|
||||
mode_manager.stop_mode("bluetooth")
|
||||
|
||||
# Thread should no longer be in output_threads or should be stopped
|
||||
assert 'bluetooth' not in mode_manager.output_threads or \
|
||||
not mode_manager.output_threads['bluetooth'].is_alive()
|
||||
assert "bluetooth" not in mode_manager.output_threads or not mode_manager.output_threads["bluetooth"].is_alive()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Multi-Mode Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestMultiMode:
|
||||
"""Test multiple modes running simultaneously."""
|
||||
|
||||
@@ -430,19 +416,19 @@ class TestMultiMode:
|
||||
"""Multiple non-SDR modes should run simultaneously."""
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
|
||||
result1 = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
|
||||
result2 = mode_manager.start_mode('tscm', {'wifi': True, 'bluetooth': False})
|
||||
result1 = mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
|
||||
result2 = mode_manager.start_mode("tscm", {"wifi": True, "bluetooth": False})
|
||||
|
||||
assert result1['status'] == 'started'
|
||||
assert result2['status'] == 'started'
|
||||
assert result1["status"] == "started"
|
||||
assert result2["status"] == "started"
|
||||
assert len(mode_manager.running_modes) == 2
|
||||
|
||||
def test_stop_all_modes(self, mode_manager, mock_subprocess, mock_tools):
|
||||
"""All modes should stop cleanly."""
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
|
||||
mode_manager.start_mode('sensor', {'device': '0'})
|
||||
mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
|
||||
mode_manager.start_mode("sensor", {"device": "0"})
|
||||
mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
|
||||
|
||||
# Stop all
|
||||
for mode in list(mode_manager.running_modes.keys()):
|
||||
@@ -455,26 +441,27 @@ class TestMultiMode:
|
||||
# GPS Integration Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGPSIntegration:
|
||||
"""Test GPS coordinate integration."""
|
||||
|
||||
def test_status_includes_gps_flag(self, mode_manager):
|
||||
"""Status should indicate GPS availability."""
|
||||
status = mode_manager.get_status()
|
||||
assert 'gps' in status
|
||||
assert "gps" in status
|
||||
|
||||
def test_mode_start_includes_gps_flag(self, mode_manager, mock_subprocess, mock_tools):
|
||||
"""Mode start response should include GPS status."""
|
||||
mock_popen, mock_proc = mock_subprocess
|
||||
|
||||
result = mode_manager.start_mode('sensor', {'device': '0'})
|
||||
if result['status'] == 'started':
|
||||
assert 'gps_enabled' in result
|
||||
result = mode_manager.start_mode("sensor", {"device": "0"})
|
||||
if result["status"] == "started":
|
||||
assert "gps_enabled" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Run Tests
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
+28
-9
@@ -1,26 +1,25 @@
|
||||
"""Tests for main application routes."""
|
||||
|
||||
|
||||
|
||||
def test_index_page(client):
|
||||
"""Test that index page loads."""
|
||||
response = client.get('/')
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b'INTERCEPT' in response.data
|
||||
assert b"INTERCEPT" in response.data
|
||||
|
||||
|
||||
def test_dependencies_endpoint(client):
|
||||
"""Test dependencies endpoint returns valid JSON."""
|
||||
response = client.get('/dependencies')
|
||||
response = client.get("/dependencies")
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert 'modes' in data
|
||||
assert 'os' in data
|
||||
assert "modes" in data
|
||||
assert "os" in data
|
||||
|
||||
|
||||
def test_devices_endpoint(client):
|
||||
"""Test devices endpoint returns list."""
|
||||
response = client.get('/devices')
|
||||
response = client.get("/devices")
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert isinstance(data, list)
|
||||
@@ -28,11 +27,31 @@ def test_devices_endpoint(client):
|
||||
|
||||
def test_satellite_dashboard(client):
|
||||
"""Test satellite dashboard loads."""
|
||||
response = client.get('/satellite/dashboard')
|
||||
response = client.get("/satellite/dashboard")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_adsb_dashboard(client):
|
||||
"""Test ADS-B dashboard loads."""
|
||||
response = client.get('/adsb/dashboard')
|
||||
response = client.get("/adsb/dashboard")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_pager_directory_elements_present(client):
|
||||
response = client.get("/")
|
||||
assert b'id="signalViewWrap"' in response.data
|
||||
assert b'id="pagerDirectoryView"' in response.data
|
||||
assert b'id="pagerDirEntries"' in response.data
|
||||
assert b'id="pagerFeedHeader"' in response.data
|
||||
assert b'id="pagerToggleDir"' in response.data
|
||||
assert b"pager-directory.css" in response.data
|
||||
assert b"pager-directory.js" in response.data
|
||||
|
||||
|
||||
def test_sensor_dashboard_elements_present(client):
|
||||
response = client.get("/")
|
||||
assert b'id="sensorDashboardView"' in response.data
|
||||
assert b'id="sensorDashboardGrid"' in response.data
|
||||
assert b'id="sensorToggleDash"' in response.data
|
||||
assert b"sensor-dashboard.css" in response.data
|
||||
assert b"sensor-dashboard.js" in response.data
|
||||
|
||||
@@ -98,10 +98,9 @@ def test_stop_scan_route(client, mock_app_module):
|
||||
|
||||
|
||||
def test_enum_services_error_no_mac(client):
|
||||
"""Test service enumeration validation."""
|
||||
"""Test service enumeration validates required mac field and returns 400."""
|
||||
response = client.post("/bt/enum", json={})
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["status"] == "error"
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_get_devices_route(client, mock_app_module):
|
||||
@@ -126,4 +125,3 @@ def test_reload_oui_route(client, mocker):
|
||||
assert response.status_code == 200
|
||||
assert data["status"] == "success"
|
||||
assert data["entries"] > 0
|
||||
|
||||
|
||||
@@ -333,7 +333,7 @@ class TestBaselineManagement:
|
||||
count = aggregator.set_baseline()
|
||||
|
||||
assert count == 1
|
||||
assert aggregator.has_baseline()
|
||||
assert aggregator.has_baseline is True
|
||||
|
||||
def test_clear_baseline(self, aggregator, sample_observation):
|
||||
"""Test clearing the baseline."""
|
||||
@@ -341,7 +341,7 @@ class TestBaselineManagement:
|
||||
aggregator.set_baseline()
|
||||
aggregator.clear_baseline()
|
||||
|
||||
assert not aggregator.has_baseline()
|
||||
assert aggregator.has_baseline is False
|
||||
|
||||
def test_is_new_device(self, aggregator, sample_observation):
|
||||
"""Test detection of new devices vs baseline."""
|
||||
@@ -432,7 +432,7 @@ class TestDevicePruning:
|
||||
aggregator.ingest(recent_obs)
|
||||
|
||||
# Prune stale devices
|
||||
pruned = aggregator.prune_stale()
|
||||
pruned = aggregator.prune_stale_devices()
|
||||
|
||||
assert pruned == 1
|
||||
devices = aggregator.get_all_devices()
|
||||
@@ -489,13 +489,14 @@ class TestDeviceFiltering:
|
||||
)
|
||||
aggregator.ingest(classic_obs)
|
||||
|
||||
# Filter by BLE
|
||||
ble_devices = aggregator.get_all_devices(protocol="ble")
|
||||
# Filter by BLE — get_all_devices() takes no args; filter in test
|
||||
all_devices = aggregator.get_all_devices()
|
||||
ble_devices = [d for d in all_devices if d.protocol == "ble"]
|
||||
assert len(ble_devices) == 1
|
||||
assert ble_devices[0].protocol == "ble"
|
||||
|
||||
# Filter by Classic
|
||||
classic_devices = aggregator.get_all_devices(protocol="classic")
|
||||
classic_devices = [d for d in all_devices if d.protocol == "classic"]
|
||||
assert len(classic_devices) == 1
|
||||
assert classic_devices[0].protocol == "classic"
|
||||
|
||||
@@ -523,8 +524,9 @@ class TestDeviceFiltering:
|
||||
)
|
||||
aggregator.ingest(obs)
|
||||
|
||||
# Filter by min RSSI -60
|
||||
strong_devices = aggregator.get_all_devices(min_rssi=-60)
|
||||
# Filter by min RSSI -60 — get_all_devices() takes no args; filter in test
|
||||
all_devices = aggregator.get_all_devices()
|
||||
strong_devices = [d for d in all_devices if d.rssi_current is not None and d.rssi_current >= -60]
|
||||
assert len(strong_devices) == 1
|
||||
assert strong_devices[0].rssi_current == -50
|
||||
|
||||
@@ -552,8 +554,9 @@ class TestDeviceFiltering:
|
||||
)
|
||||
aggregator.ingest(obs)
|
||||
|
||||
# Sort by RSSI (strongest first)
|
||||
devices = aggregator.get_all_devices(sort_by="rssi")
|
||||
# Sort by RSSI (strongest first) — get_all_devices() takes no args; sort in test
|
||||
all_devices = aggregator.get_all_devices()
|
||||
devices = sorted(all_devices, key=lambda d: d.rssi_current or -999, reverse=True)
|
||||
rssi_values = [d.rssi_current for d in devices]
|
||||
assert rssi_values == [-50, -60, -70, -90]
|
||||
|
||||
|
||||
+131
-113
@@ -7,7 +7,7 @@ import pytest
|
||||
from flask import Flask
|
||||
|
||||
from routes.bluetooth_v2 import bluetooth_v2_bp
|
||||
from utils.bluetooth.models import BTDeviceAggregate, SystemCapabilities
|
||||
from utils.bluetooth.models import BTDeviceAggregate, ScanStatus, SystemCapabilities
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -15,7 +15,7 @@ def app():
|
||||
"""Create Flask application for testing."""
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(bluetooth_v2_bp)
|
||||
app.config['TESTING'] = True
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
|
||||
@@ -28,12 +28,18 @@ def client(app):
|
||||
@pytest.fixture
|
||||
def mock_scanner():
|
||||
"""Create mock BluetoothScanner."""
|
||||
with patch('routes.bluetooth_v2.get_bluetooth_scanner') as mock_get:
|
||||
with patch("routes.bluetooth_v2.get_bluetooth_scanner") as mock_get:
|
||||
scanner = MagicMock()
|
||||
scanner.is_scanning = False
|
||||
scanner.scan_mode = None
|
||||
scanner.scan_start_time = None
|
||||
scanner.device_count = 0
|
||||
scanner.get_status.return_value = ScanStatus(
|
||||
is_scanning=False,
|
||||
mode="auto",
|
||||
backend=None,
|
||||
adapter_id=None,
|
||||
)
|
||||
mock_get.return_value = scanner
|
||||
yield scanner
|
||||
|
||||
@@ -78,61 +84,73 @@ class TestScanEndpoints:
|
||||
def test_start_scan_success(self, client, mock_scanner):
|
||||
"""Test starting a scan successfully."""
|
||||
mock_scanner.start_scan.return_value = True
|
||||
mock_scanner.scan_mode = "dbus"
|
||||
mock_scanner.get_status.return_value = ScanStatus(
|
||||
is_scanning=True,
|
||||
mode="dbus",
|
||||
backend="dbus",
|
||||
adapter_id="/org/bluez/hci0",
|
||||
)
|
||||
|
||||
response = client.post('/api/bluetooth/scan/start',
|
||||
json={'mode': 'auto', 'duration_s': 30})
|
||||
response = client.post("/api/bluetooth/scan/start", json={"mode": "auto", "duration_s": 30})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'started'
|
||||
assert data["status"] == "started"
|
||||
mock_scanner.start_scan.assert_called_once()
|
||||
|
||||
def test_start_scan_already_scanning(self, client, mock_scanner):
|
||||
"""Test starting scan when already scanning."""
|
||||
mock_scanner.is_scanning = True
|
||||
|
||||
response = client.post('/api/bluetooth/scan/start', json={})
|
||||
response = client.post("/api/bluetooth/scan/start", json={})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'already_scanning'
|
||||
assert data["status"] == "already_scanning"
|
||||
|
||||
def test_start_scan_failed(self, client, mock_scanner):
|
||||
"""Test start scan failure."""
|
||||
mock_scanner.start_scan.return_value = False
|
||||
mock_scanner.get_status.return_value = ScanStatus(
|
||||
is_scanning=False,
|
||||
mode="auto",
|
||||
error="Failed to start scan",
|
||||
)
|
||||
|
||||
response = client.post('/api/bluetooth/scan/start', json={})
|
||||
response = client.post("/api/bluetooth/scan/start", json={})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 500
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'error'
|
||||
assert data["status"] == "failed"
|
||||
|
||||
def test_stop_scan_success(self, client, mock_scanner):
|
||||
"""Test stopping a scan."""
|
||||
mock_scanner.is_scanning = True
|
||||
|
||||
response = client.post('/api/bluetooth/scan/stop')
|
||||
response = client.post("/api/bluetooth/scan/stop")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'stopped'
|
||||
assert data["status"] == "stopped"
|
||||
mock_scanner.stop_scan.assert_called_once()
|
||||
|
||||
def test_get_scan_status(self, client, mock_scanner):
|
||||
"""Test getting scan status."""
|
||||
mock_scanner.is_scanning = True
|
||||
mock_scanner.scan_mode = "dbus"
|
||||
mock_scanner.device_count = 10
|
||||
mock_scanner.get_baseline_count.return_value = 5
|
||||
mock_scanner.get_status.return_value = ScanStatus(
|
||||
is_scanning=True,
|
||||
mode="dbus",
|
||||
backend="dbus",
|
||||
adapter_id="/org/bluez/hci0",
|
||||
devices_found=10,
|
||||
)
|
||||
|
||||
response = client.get('/api/bluetooth/scan/status')
|
||||
response = client.get("/api/bluetooth/scan/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['is_scanning'] is True
|
||||
assert data['mode'] == 'dbus'
|
||||
assert data['device_count'] == 10
|
||||
assert data["is_scanning"] is True
|
||||
assert data["mode"] == "dbus"
|
||||
assert data["devices_found"] == 10
|
||||
|
||||
|
||||
class TestDeviceEndpoints:
|
||||
@@ -142,62 +160,64 @@ class TestDeviceEndpoints:
|
||||
"""Test listing all devices."""
|
||||
mock_scanner.get_devices.return_value = [sample_device]
|
||||
|
||||
response = client.get('/api/bluetooth/devices')
|
||||
response = client.get("/api/bluetooth/devices")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert len(data['devices']) == 1
|
||||
assert data['devices'][0]['address'] == 'AA:BB:CC:DD:EE:FF'
|
||||
assert len(data["devices"]) == 1
|
||||
assert data["devices"][0]["address"] == "AA:BB:CC:DD:EE:FF"
|
||||
|
||||
def test_list_devices_with_filters(self, client, mock_scanner, sample_device):
|
||||
"""Test listing devices with filters."""
|
||||
mock_scanner.get_devices.return_value = [sample_device]
|
||||
|
||||
response = client.get('/api/bluetooth/devices?protocol=ble&min_rssi=-60&sort_by=rssi')
|
||||
response = client.get("/api/bluetooth/devices?protocol=ble&min_rssi=-60&sort=rssi_current")
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_scanner.get_devices.assert_called_with(
|
||||
sort_by='rssi',
|
||||
protocol='ble',
|
||||
sort_by="rssi_current",
|
||||
sort_desc=True,
|
||||
protocol="ble",
|
||||
min_rssi=-60,
|
||||
new_only=False,
|
||||
max_age_seconds=300.0,
|
||||
)
|
||||
|
||||
def test_list_devices_new_only(self, client, mock_scanner, sample_device):
|
||||
"""Test listing only new devices."""
|
||||
"""Test listing only new devices via heuristic filter."""
|
||||
sample_device.is_new = True
|
||||
mock_scanner.get_devices.return_value = [sample_device]
|
||||
|
||||
response = client.get('/api/bluetooth/devices?new_only=true')
|
||||
response = client.get("/api/bluetooth/devices?heuristic=new")
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_scanner.get_devices.assert_called_with(
|
||||
sort_by='last_seen',
|
||||
sort_by="last_seen",
|
||||
sort_desc=True,
|
||||
protocol=None,
|
||||
min_rssi=None,
|
||||
new_only=True,
|
||||
max_age_seconds=300.0,
|
||||
)
|
||||
|
||||
def test_get_device_detail(self, client, mock_scanner, sample_device):
|
||||
"""Test getting device details."""
|
||||
mock_scanner.get_device.return_value = sample_device
|
||||
|
||||
response = client.get('/api/bluetooth/devices/AA:BB:CC:DD:EE:FF:public')
|
||||
response = client.get("/api/bluetooth/devices/AA:BB:CC:DD:EE:FF:public")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['address'] == 'AA:BB:CC:DD:EE:FF'
|
||||
assert data['manufacturer_name'] == 'Apple, Inc.'
|
||||
assert data["address"] == "AA:BB:CC:DD:EE:FF"
|
||||
assert data["manufacturer_name"] == "Apple, Inc."
|
||||
|
||||
def test_get_device_not_found(self, client, mock_scanner):
|
||||
"""Test getting non-existent device."""
|
||||
mock_scanner.get_device.return_value = None
|
||||
|
||||
response = client.get('/api/bluetooth/devices/NONEXISTENT')
|
||||
response = client.get("/api/bluetooth/devices/NONEXISTENT")
|
||||
|
||||
assert response.status_code == 404
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'error'
|
||||
assert data["status"] == "error"
|
||||
|
||||
|
||||
class TestBaselineEndpoints:
|
||||
@@ -206,21 +226,22 @@ class TestBaselineEndpoints:
|
||||
def test_set_baseline(self, client, mock_scanner):
|
||||
"""Test setting baseline."""
|
||||
mock_scanner.set_baseline.return_value = 15
|
||||
mock_scanner.get_devices.return_value = []
|
||||
|
||||
response = client.post('/api/bluetooth/baseline/set')
|
||||
response = client.post("/api/bluetooth/baseline/set", json={})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'success'
|
||||
assert data['device_count'] == 15
|
||||
assert data["status"] == "success"
|
||||
assert data["device_count"] == 15
|
||||
|
||||
def test_clear_baseline(self, client, mock_scanner):
|
||||
"""Test clearing baseline."""
|
||||
response = client.post('/api/bluetooth/baseline/clear')
|
||||
response = client.post("/api/bluetooth/baseline/clear")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'success'
|
||||
assert data["status"] in ("cleared", "no_baseline")
|
||||
mock_scanner.clear_baseline.assert_called_once()
|
||||
|
||||
|
||||
@@ -230,46 +251,40 @@ class TestCapabilitiesEndpoint:
|
||||
def test_get_capabilities(self, client):
|
||||
"""Test getting system capabilities."""
|
||||
mock_caps = SystemCapabilities(
|
||||
available=True,
|
||||
dbus_available=True,
|
||||
has_dbus=True,
|
||||
has_bluez=True,
|
||||
bluez_version="5.66",
|
||||
adapters=[],
|
||||
has_root=True,
|
||||
rfkill_blocked=False,
|
||||
fallback_tools=['bleak', 'hcitool'],
|
||||
issues=[],
|
||||
preferred_backend='dbus',
|
||||
adapters=[{"id": "/org/bluez/hci0", "name": "hci0"}],
|
||||
is_root=True,
|
||||
has_bleak=True,
|
||||
has_hcitool=True,
|
||||
)
|
||||
|
||||
with patch('routes.bluetooth_v2.check_bluetooth_capabilities', return_value=mock_caps):
|
||||
response = client.get('/api/bluetooth/capabilities')
|
||||
with patch("routes.bluetooth_v2.check_capabilities", return_value=mock_caps):
|
||||
response = client.get("/api/bluetooth/capabilities")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['available'] is True
|
||||
assert data['dbus_available'] is True
|
||||
assert data["available"] is True
|
||||
assert data["has_dbus"] is True
|
||||
|
||||
def test_capabilities_not_available(self, client):
|
||||
"""Test capabilities when Bluetooth not available."""
|
||||
mock_caps = SystemCapabilities(
|
||||
available=False,
|
||||
dbus_available=False,
|
||||
has_dbus=False,
|
||||
has_bluez=False,
|
||||
bluez_version=None,
|
||||
adapters=[],
|
||||
has_root=False,
|
||||
rfkill_blocked=False,
|
||||
fallback_tools=[],
|
||||
issues=['No Bluetooth adapter found'],
|
||||
preferred_backend=None,
|
||||
issues=["No Bluetooth adapter found"],
|
||||
)
|
||||
|
||||
with patch('routes.bluetooth_v2.check_bluetooth_capabilities', return_value=mock_caps):
|
||||
response = client.get('/api/bluetooth/capabilities')
|
||||
with patch("routes.bluetooth_v2.check_capabilities", return_value=mock_caps):
|
||||
response = client.get("/api/bluetooth/capabilities")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['available'] is False
|
||||
assert 'No Bluetooth adapter found' in data['issues']
|
||||
assert data["available"] is False
|
||||
assert "No Bluetooth adapter found" in data["issues"]
|
||||
|
||||
|
||||
class TestExportEndpoint:
|
||||
@@ -279,37 +294,37 @@ class TestExportEndpoint:
|
||||
"""Test JSON export."""
|
||||
mock_scanner.get_devices.return_value = [sample_device]
|
||||
|
||||
response = client.get('/api/bluetooth/export?format=json')
|
||||
response = client.get("/api/bluetooth/export?format=json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content_type == 'application/json'
|
||||
assert response.content_type == "application/json"
|
||||
data = response.get_json()
|
||||
assert 'devices' in data
|
||||
assert 'timestamp' in data
|
||||
assert "devices" in data
|
||||
assert "exported_at" in data
|
||||
|
||||
def test_export_csv(self, client, mock_scanner, sample_device):
|
||||
"""Test CSV export."""
|
||||
mock_scanner.get_devices.return_value = [sample_device]
|
||||
|
||||
response = client.get('/api/bluetooth/export?format=csv')
|
||||
response = client.get("/api/bluetooth/export?format=csv")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'text/csv' in response.content_type
|
||||
assert "text/csv" in response.content_type
|
||||
|
||||
# Check CSV content
|
||||
csv_content = response.data.decode('utf-8')
|
||||
assert 'address' in csv_content.lower()
|
||||
assert 'AA:BB:CC:DD:EE:FF' in csv_content
|
||||
csv_content = response.data.decode("utf-8")
|
||||
assert "address" in csv_content.lower()
|
||||
assert "AA:BB:CC:DD:EE:FF" in csv_content
|
||||
|
||||
def test_export_empty_devices(self, client, mock_scanner):
|
||||
"""Test export with no devices."""
|
||||
mock_scanner.get_devices.return_value = []
|
||||
|
||||
response = client.get('/api/bluetooth/export?format=json')
|
||||
response = client.get("/api/bluetooth/export?format=json")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['devices'] == []
|
||||
assert data["devices"] == []
|
||||
|
||||
|
||||
class TestStreamEndpoint:
|
||||
@@ -319,18 +334,18 @@ class TestStreamEndpoint:
|
||||
"""Test SSE stream has correct headers."""
|
||||
mock_scanner.stream_events.return_value = iter([])
|
||||
|
||||
response = client.get('/api/bluetooth/stream')
|
||||
response = client.get("/api/bluetooth/stream")
|
||||
|
||||
assert response.content_type == 'text/event-stream'
|
||||
assert response.headers.get('Cache-Control') == 'no-cache'
|
||||
assert response.content_type.startswith("text/event-stream")
|
||||
assert response.headers.get("Cache-Control") == "no-cache"
|
||||
|
||||
def test_stream_returns_generator(self, client, mock_scanner):
|
||||
"""Test stream endpoint returns a generator response."""
|
||||
mock_scanner.stream_events.return_value = iter([
|
||||
{'event': 'device_update', 'data': {'address': 'AA:BB:CC:DD:EE:FF'}}
|
||||
])
|
||||
mock_scanner.stream_events.return_value = iter(
|
||||
[{"event": "device_update", "data": {"address": "AA:BB:CC:DD:EE:FF"}}]
|
||||
)
|
||||
|
||||
response = client.get('/api/bluetooth/stream')
|
||||
response = client.get("/api/bluetooth/stream")
|
||||
|
||||
# Should be a streaming response
|
||||
assert response.is_streamed is True
|
||||
@@ -345,14 +360,14 @@ class TestTSCMIntegration:
|
||||
|
||||
mock_scanner.get_devices.return_value = [sample_device]
|
||||
|
||||
with patch('routes.bluetooth_v2.get_bluetooth_scanner', return_value=mock_scanner):
|
||||
with patch("routes.bluetooth_v2.get_bluetooth_scanner", return_value=mock_scanner):
|
||||
devices = get_tscm_bluetooth_snapshot(duration=8)
|
||||
|
||||
assert len(devices) == 1
|
||||
device = devices[0]
|
||||
# Should be converted to TSCM format
|
||||
assert 'mac' in device
|
||||
assert device['mac'] == 'AA:BB:CC:DD:EE:FF'
|
||||
assert "mac" in device
|
||||
assert device["mac"] == "AA:BB:CC:DD:EE:FF"
|
||||
|
||||
def test_tscm_snapshot_empty(self, mock_scanner):
|
||||
"""Test TSCM snapshot with no devices."""
|
||||
@@ -360,7 +375,7 @@ class TestTSCMIntegration:
|
||||
|
||||
mock_scanner.get_devices.return_value = []
|
||||
|
||||
with patch('routes.bluetooth_v2.get_bluetooth_scanner', return_value=mock_scanner):
|
||||
with patch("routes.bluetooth_v2.get_bluetooth_scanner", return_value=mock_scanner):
|
||||
devices = get_tscm_bluetooth_snapshot()
|
||||
|
||||
assert devices == []
|
||||
@@ -371,29 +386,32 @@ class TestErrorHandling:
|
||||
|
||||
def test_invalid_json_body(self, client, mock_scanner):
|
||||
"""Test handling of invalid JSON body."""
|
||||
response = client.post('/api/bluetooth/scan/start',
|
||||
data='not json',
|
||||
content_type='application/json')
|
||||
response = client.post("/api/bluetooth/scan/start", data="not json", content_type="application/json")
|
||||
|
||||
# Should handle gracefully
|
||||
assert response.status_code in [200, 400]
|
||||
|
||||
def test_scanner_exception(self, client, mock_scanner):
|
||||
"""Test handling of scanner exceptions."""
|
||||
mock_scanner.start_scan.side_effect = Exception("Scanner error")
|
||||
"""Test handling of scanner failure — route returns 'failed' with HTTP 500."""
|
||||
mock_scanner.start_scan.return_value = False
|
||||
mock_scanner.get_status.return_value = ScanStatus(
|
||||
is_scanning=False,
|
||||
mode="auto",
|
||||
error="Scanner error",
|
||||
)
|
||||
|
||||
response = client.post('/api/bluetooth/scan/start', json={})
|
||||
response = client.post("/api/bluetooth/scan/start", json={})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 500
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'error'
|
||||
assert 'error' in data['message'].lower() or 'Scanner error' in data['message']
|
||||
assert data["status"] == "failed"
|
||||
assert "Scanner error" in data["error"]
|
||||
|
||||
def test_invalid_device_id_format(self, client, mock_scanner):
|
||||
"""Test handling of invalid device ID format."""
|
||||
mock_scanner.get_device.return_value = None
|
||||
|
||||
response = client.get('/api/bluetooth/devices/invalid-id-format')
|
||||
response = client.get("/api/bluetooth/devices/invalid-id-format")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@@ -407,16 +425,16 @@ class TestDeviceSerialization:
|
||||
|
||||
result = device_to_dict(sample_device)
|
||||
|
||||
assert result['device_id'] == sample_device.device_id
|
||||
assert result['address'] == sample_device.address
|
||||
assert result['address_type'] == sample_device.address_type
|
||||
assert result['protocol'] == sample_device.protocol
|
||||
assert result['rssi_current'] == sample_device.rssi_current
|
||||
assert result['rssi_median'] == sample_device.rssi_median
|
||||
assert result['range_band'] == sample_device.range_band
|
||||
assert result['is_new'] == sample_device.is_new
|
||||
assert result['is_persistent'] == sample_device.is_persistent
|
||||
assert result['manufacturer_name'] == sample_device.manufacturer_name
|
||||
assert result["device_id"] == sample_device.device_id
|
||||
assert result["address"] == sample_device.address
|
||||
assert result["address_type"] == sample_device.address_type
|
||||
assert result["protocol"] == sample_device.protocol
|
||||
assert result["rssi_current"] == sample_device.rssi_current
|
||||
assert result["rssi_median"] == sample_device.rssi_median
|
||||
assert result["range_band"] == sample_device.range_band
|
||||
assert result["is_new"] == sample_device.is_new
|
||||
assert result["is_persistent"] == sample_device.is_persistent
|
||||
assert result["manufacturer_name"] == sample_device.manufacturer_name
|
||||
|
||||
def test_device_to_dict_timestamps(self, sample_device):
|
||||
"""Test device serialization handles timestamps correctly."""
|
||||
@@ -425,8 +443,8 @@ class TestDeviceSerialization:
|
||||
result = device_to_dict(sample_device)
|
||||
|
||||
# Timestamps should be ISO format strings
|
||||
assert isinstance(result['first_seen'], str)
|
||||
assert isinstance(result['last_seen'], str)
|
||||
assert isinstance(result["first_seen"], str)
|
||||
assert isinstance(result["last_seen"], str)
|
||||
|
||||
def test_device_to_dict_null_values(self):
|
||||
"""Test device serialization handles null values."""
|
||||
@@ -464,6 +482,6 @@ class TestDeviceSerialization:
|
||||
|
||||
result = device_to_dict(device)
|
||||
|
||||
assert result['rssi_current'] is None
|
||||
assert result['name'] is None
|
||||
assert result['manufacturer_name'] is None
|
||||
assert result["rssi_current"] is None
|
||||
assert result["name"] is None
|
||||
assert result["manufacturer_name"] is None
|
||||
|
||||
@@ -4,9 +4,6 @@ from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from utils.bluetooth.constants import (
|
||||
BEACON_INTERVAL_MAX_VARIANCE as HEURISTIC_BEACON_VARIANCE_THRESHOLD,
|
||||
)
|
||||
from utils.bluetooth.constants import (
|
||||
PERSISTENT_MIN_SEEN_COUNT as HEURISTIC_PERSISTENT_MIN_SEEN,
|
||||
)
|
||||
@@ -19,7 +16,7 @@ from utils.bluetooth.constants import (
|
||||
from utils.bluetooth.constants import (
|
||||
STRONG_RSSI_THRESHOLD as HEURISTIC_STRONG_STABLE_RSSI,
|
||||
)
|
||||
from utils.bluetooth.heuristics import HeuristicsEngine
|
||||
from utils.bluetooth.heuristics import HeuristicsEngine, evaluate_all_devices
|
||||
from utils.bluetooth.models import BTDeviceAggregate
|
||||
|
||||
|
||||
@@ -36,6 +33,7 @@ def create_device_aggregate(
|
||||
first_seen=None,
|
||||
last_seen=None,
|
||||
seen_count=1,
|
||||
seen_rate=None,
|
||||
rssi_current=-60,
|
||||
rssi_median=-60,
|
||||
rssi_variance=5.0,
|
||||
@@ -50,6 +48,8 @@ def create_device_aggregate(
|
||||
last_seen = now
|
||||
if rssi_samples is None:
|
||||
rssi_samples = [(now, rssi_current)]
|
||||
if seen_rate is None:
|
||||
seen_rate = seen_count / 60.0
|
||||
|
||||
return BTDeviceAggregate(
|
||||
device_id=f"{address}:{address_type}",
|
||||
@@ -59,12 +59,12 @@ def create_device_aggregate(
|
||||
first_seen=first_seen,
|
||||
last_seen=last_seen,
|
||||
seen_count=seen_count,
|
||||
seen_rate=seen_count / 60.0,
|
||||
seen_rate=seen_rate,
|
||||
rssi_samples=rssi_samples,
|
||||
rssi_current=rssi_current,
|
||||
rssi_median=rssi_median,
|
||||
rssi_min=rssi_median - 10,
|
||||
rssi_max=rssi_median + 10,
|
||||
rssi_min=(rssi_median - 10) if rssi_median is not None else None,
|
||||
rssi_max=(rssi_median + 10) if rssi_median is not None else None,
|
||||
rssi_variance=rssi_variance,
|
||||
rssi_confidence=0.8,
|
||||
range_band="nearby",
|
||||
@@ -86,10 +86,12 @@ class TestPersistentHeuristic:
|
||||
"""Tests for persistent device detection."""
|
||||
|
||||
def test_persistent_high_seen_count(self, engine):
|
||||
"""Test device with high seen count is marked persistent."""
|
||||
"""Test device with high seen count and adequate rate/duration is marked persistent."""
|
||||
# _check_persistent requires: seen_count >= 10, duration >= 150s, seen_rate >= 2.0
|
||||
device = create_device_aggregate(
|
||||
seen_count=HEURISTIC_PERSISTENT_MIN_SEEN + 5,
|
||||
first_seen=datetime.now() - timedelta(seconds=HEURISTIC_PERSISTENT_WINDOW_SECONDS - 60),
|
||||
seen_rate=2.5, # satisfies >= 2.0/min threshold
|
||||
first_seen=datetime.now() - timedelta(seconds=HEURISTIC_PERSISTENT_WINDOW_SECONDS),
|
||||
)
|
||||
|
||||
result = engine.evaluate(device)
|
||||
@@ -103,14 +105,16 @@ class TestPersistentHeuristic:
|
||||
assert result.is_persistent is False
|
||||
|
||||
def test_not_persistent_outside_window(self, engine):
|
||||
"""Test device seen long ago is not persistent."""
|
||||
"""Test device seen long ago with adequate rate is still persistent."""
|
||||
# duration > PERSISTENT_WINDOW_SECONDS*0.5 is satisfied; rate must be >= 2.0
|
||||
device = create_device_aggregate(
|
||||
seen_count=HEURISTIC_PERSISTENT_MIN_SEEN + 5,
|
||||
seen_rate=2.5,
|
||||
first_seen=datetime.now() - timedelta(seconds=HEURISTIC_PERSISTENT_WINDOW_SECONDS + 3600),
|
||||
)
|
||||
|
||||
result = engine.evaluate(device)
|
||||
# Should still be considered persistent if high seen count
|
||||
# Long duration + adequate rate + sufficient count → still persistent
|
||||
assert result.is_persistent is True
|
||||
|
||||
|
||||
@@ -120,19 +124,18 @@ class TestBeaconLikeHeuristic:
|
||||
def test_beacon_like_stable_intervals(self, engine):
|
||||
"""Test device with stable advertisement intervals is beacon-like."""
|
||||
now = datetime.now()
|
||||
# Create samples with very stable intervals (every 1 second)
|
||||
rssi_samples = [(now - timedelta(seconds=i), -60) for i in range(20)]
|
||||
# Create samples in chronological order (oldest first) with 1-second spacing
|
||||
# so that _calculate_intervals sees positive intervals ~ 1s each (cv ~ 0 < 0.10)
|
||||
rssi_samples = [(now - timedelta(seconds=(19 - i)), -60) for i in range(20)]
|
||||
|
||||
device = create_device_aggregate(
|
||||
seen_count=20,
|
||||
rssi_samples=rssi_samples,
|
||||
rssi_variance=1.0, # Very low variance
|
||||
rssi_variance=1.0,
|
||||
)
|
||||
|
||||
result = engine.evaluate(device)
|
||||
# Beacon-like depends on interval analysis
|
||||
# With regular samples, should detect beacon-like behavior
|
||||
assert result.is_beacon_like is True or result.rssi_variance < HEURISTIC_BEACON_VARIANCE_THRESHOLD
|
||||
assert result.is_beacon_like is True
|
||||
|
||||
def test_not_beacon_like_irregular_intervals(self, engine):
|
||||
"""Test device with irregular intervals is not beacon-like."""
|
||||
@@ -163,11 +166,15 @@ class TestStrongStableHeuristic:
|
||||
|
||||
def test_strong_stable_device(self, engine):
|
||||
"""Test device with strong, stable signal."""
|
||||
now = datetime.now()
|
||||
rssi_val = HEURISTIC_STRONG_STABLE_RSSI + 5 # Stronger than threshold
|
||||
rssi_samples = [(now - timedelta(seconds=i), rssi_val) for i in range(5)]
|
||||
device = create_device_aggregate(
|
||||
rssi_current=HEURISTIC_STRONG_STABLE_RSSI + 5, # Stronger than threshold
|
||||
rssi_median=HEURISTIC_STRONG_STABLE_RSSI + 5,
|
||||
rssi_current=rssi_val,
|
||||
rssi_median=rssi_val,
|
||||
rssi_variance=HEURISTIC_STRONG_STABLE_VARIANCE - 1, # Less variance than threshold
|
||||
seen_count=15,
|
||||
rssi_samples=rssi_samples,
|
||||
)
|
||||
|
||||
result = engine.evaluate(device)
|
||||
@@ -246,12 +253,18 @@ class TestMultipleHeuristics:
|
||||
|
||||
def test_multiple_flags_can_be_true(self, engine):
|
||||
"""Test device can have multiple heuristic flags."""
|
||||
now = datetime.now()
|
||||
rssi_val = HEURISTIC_STRONG_STABLE_RSSI + 10
|
||||
rssi_samples = [(now - timedelta(seconds=i), rssi_val) for i in range(5)]
|
||||
device = create_device_aggregate(
|
||||
address_type="random",
|
||||
seen_count=HEURISTIC_PERSISTENT_MIN_SEEN + 5,
|
||||
rssi_current=HEURISTIC_STRONG_STABLE_RSSI + 10,
|
||||
rssi_median=HEURISTIC_STRONG_STABLE_RSSI + 10,
|
||||
seen_rate=2.5,
|
||||
first_seen=datetime.now() - timedelta(seconds=HEURISTIC_PERSISTENT_WINDOW_SECONDS),
|
||||
rssi_current=rssi_val,
|
||||
rssi_median=rssi_val,
|
||||
rssi_variance=1.0,
|
||||
rssi_samples=rssi_samples,
|
||||
is_new=True,
|
||||
)
|
||||
|
||||
@@ -286,7 +299,7 @@ class TestHeuristicsBatchEvaluation:
|
||||
"""Tests for batch evaluation of multiple devices."""
|
||||
|
||||
def test_evaluate_multiple_devices(self, engine):
|
||||
"""Test evaluating multiple devices at once."""
|
||||
"""Test evaluating multiple devices at once via evaluate_all_devices."""
|
||||
devices = [
|
||||
create_device_aggregate(
|
||||
address=f"AA:BB:CC:DD:EE:{i:02X}",
|
||||
@@ -295,18 +308,17 @@ class TestHeuristicsBatchEvaluation:
|
||||
for i in range(1, 6)
|
||||
]
|
||||
|
||||
results = engine.evaluate_batch(devices)
|
||||
# evaluate_all_devices evaluates in-place; each device is a BTDeviceAggregate
|
||||
evaluate_all_devices(devices)
|
||||
|
||||
assert len(results) == 5
|
||||
# Device with highest seen count should be persistent
|
||||
most_seen = max(results, key=lambda d: d.seen_count)
|
||||
# May or may not be persistent depending on exact thresholds
|
||||
assert len(devices) == 5
|
||||
# Device with highest seen count should have a valid bool flag
|
||||
most_seen = max(devices, key=lambda d: d.seen_count)
|
||||
assert isinstance(most_seen.is_persistent, bool)
|
||||
|
||||
def test_evaluate_empty_list(self, engine):
|
||||
"""Test evaluating empty device list."""
|
||||
results = engine.evaluate_batch([])
|
||||
assert results == []
|
||||
"""Test evaluating empty device list is a no-op."""
|
||||
evaluate_all_devices([]) # should not raise
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
@@ -353,12 +365,15 @@ class TestEdgeCases:
|
||||
result = engine.evaluate(device)
|
||||
assert result.is_strong_stable is False
|
||||
|
||||
# Test strongest possible
|
||||
# Test strongest possible — needs ≥5 rssi_samples for _check_strong_stable
|
||||
now = datetime.now()
|
||||
rssi_samples = [(now - timedelta(seconds=i), -20) for i in range(5)]
|
||||
device2 = create_device_aggregate(
|
||||
rssi_current=-20, # Very strong
|
||||
rssi_median=-20,
|
||||
rssi_variance=1.0,
|
||||
seen_count=10,
|
||||
rssi_samples=rssi_samples,
|
||||
)
|
||||
|
||||
result2 = engine.evaluate(device2)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Tests for shared capability detection."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from utils.capabilities import detect_interfaces, detect_mode_availability
|
||||
|
||||
|
||||
class TestModeAvailability:
|
||||
def test_all_tools_present(self):
|
||||
with patch("utils.capabilities.check_all_dependencies") as mock_deps:
|
||||
mock_deps.return_value = {
|
||||
key: {"ready": True}
|
||||
for key in (
|
||||
"pager",
|
||||
"sensor",
|
||||
"aircraft",
|
||||
"ais",
|
||||
"acars",
|
||||
"aprs",
|
||||
"wifi",
|
||||
"bluetooth",
|
||||
"tscm",
|
||||
"satellite",
|
||||
)
|
||||
}
|
||||
modes = detect_mode_availability()
|
||||
assert modes.get("sensor") is True
|
||||
assert modes.get("pager") is True
|
||||
assert modes.get("adsb") is True # maps from dep key "aircraft"
|
||||
|
||||
def test_no_tools_present(self):
|
||||
with patch("utils.capabilities.check_all_dependencies") as mock_deps:
|
||||
mock_deps.return_value = {}
|
||||
modes = detect_mode_availability()
|
||||
assert modes.get("sensor") is False
|
||||
|
||||
def test_pre_computed_dep_status_skips_probe(self):
|
||||
"""Passing dep_status must not trigger a second check_all_dependencies call."""
|
||||
pre_computed = {
|
||||
key: {"ready": True}
|
||||
for key in (
|
||||
"pager",
|
||||
"sensor",
|
||||
"aircraft",
|
||||
"ais",
|
||||
"acars",
|
||||
"aprs",
|
||||
"wifi",
|
||||
"bluetooth",
|
||||
"tscm",
|
||||
"satellite",
|
||||
)
|
||||
}
|
||||
with patch("utils.capabilities.check_all_dependencies") as mock_deps:
|
||||
modes = detect_mode_availability(dep_status=pre_computed)
|
||||
mock_deps.assert_not_called()
|
||||
assert modes.get("sensor") is True
|
||||
assert modes.get("adsb") is True
|
||||
|
||||
|
||||
class TestInterfaceDetection:
|
||||
def test_darwin_wifi_parsing(self):
|
||||
"""The Darwin branch must parse a Wi-Fi device out of networksetup output."""
|
||||
networksetup_output = (
|
||||
"Hardware Port: Wi-Fi\n"
|
||||
"Device: en0\n"
|
||||
"Ethernet Address: aa:bb:cc:dd:ee:ff\n"
|
||||
"\n"
|
||||
"Hardware Port: Thunderbolt Bridge\n"
|
||||
"Device: bridge0\n"
|
||||
)
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
result = MagicMock()
|
||||
result.stdout = networksetup_output
|
||||
result.stderr = ""
|
||||
result.returncode = 0
|
||||
return result
|
||||
|
||||
with (
|
||||
patch("utils.capabilities.platform.system", return_value="Darwin"),
|
||||
patch("subprocess.run", side_effect=fake_run),
|
||||
):
|
||||
interfaces = detect_interfaces()
|
||||
|
||||
names = [i["name"] for i in interfaces["wifi_interfaces"]]
|
||||
assert "en0" in names
|
||||
# Verify the full shape of the parsed entry
|
||||
en0 = next(i for i in interfaces["wifi_interfaces"] if i["name"] == "en0")
|
||||
assert "display_name" in en0
|
||||
assert "type" in en0
|
||||
assert "monitor_capable" in en0
|
||||
# Thunderbolt Bridge must not appear — it has no Wi-Fi/AirPort keyword
|
||||
assert "bridge0" not in names
|
||||
|
||||
|
||||
class TestFallback:
|
||||
def test_fallback_uses_check_tool(self):
|
||||
"""When check_all_dependencies raises, fall back to per-tool checks."""
|
||||
with (
|
||||
patch(
|
||||
"utils.capabilities.check_all_dependencies",
|
||||
side_effect=RuntimeError("module unavailable"),
|
||||
),
|
||||
patch("utils.capabilities.check_tool", return_value=False) as mock_check,
|
||||
):
|
||||
modes = detect_mode_availability()
|
||||
assert modes.get("sensor") is False
|
||||
assert mock_check.called
|
||||
|
||||
def test_fallback_extra_mode_tools(self):
|
||||
"""EXTRA_MODE_TOOLS modes (dsc, rtlamr, listening_post) reflect check_tool's return."""
|
||||
with (
|
||||
patch(
|
||||
"utils.capabilities.check_all_dependencies",
|
||||
side_effect=RuntimeError("module unavailable"),
|
||||
),
|
||||
patch("utils.capabilities.check_tool", return_value=False),
|
||||
):
|
||||
modes = detect_mode_availability()
|
||||
assert modes.get("dsc") is False
|
||||
assert modes.get("rtlamr") is False
|
||||
assert modes.get("listening_post") is False
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Tests for shared conftest fixtures."""
|
||||
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestFakeProcess:
|
||||
def test_works_with_subprocess_run(self, fake_process):
|
||||
"""subprocess.run() must unpack communicate() and enter the context manager."""
|
||||
proc = fake_process(stdout="hello", stderr="", returncode=0)
|
||||
with patch("subprocess.Popen", return_value=proc):
|
||||
result = subprocess.run(["anything"], capture_output=True, text=True, timeout=5)
|
||||
assert result.stdout == "hello"
|
||||
|
||||
def test_running_process_defaults(self, fake_process):
|
||||
proc = fake_process()
|
||||
assert proc.poll() is None # still running
|
||||
assert proc.pid == 12345
|
||||
assert proc.communicate() == ("", "")
|
||||
|
||||
def test_exited_process_detected_via_run(self, fake_process):
|
||||
"""An exited process's returncode and stderr surface through subprocess.run."""
|
||||
proc = fake_process(running=False, returncode=1, stdout=b"", stderr=b"device busy")
|
||||
with patch("subprocess.Popen", return_value=proc):
|
||||
result = subprocess.run(["anything"], capture_output=True)
|
||||
assert result.returncode == 1
|
||||
assert result.stderr == b"device busy"
|
||||
+237
-194
@@ -23,18 +23,19 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
# Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_db(tmp_path):
|
||||
"""Set up a temporary database."""
|
||||
import utils.database as db_module
|
||||
from utils.database import init_db
|
||||
|
||||
test_db_path = tmp_path / 'test.db'
|
||||
test_db_path = tmp_path / "test.db"
|
||||
original_db_path = db_module.DB_PATH
|
||||
db_module.DB_PATH = test_db_path
|
||||
db_module.DB_DIR = tmp_path
|
||||
|
||||
if hasattr(db_module._local, 'connection') and db_module._local.connection:
|
||||
if hasattr(db_module._local, "connection") and db_module._local.connection:
|
||||
db_module._local.connection.close()
|
||||
db_module._local.connection = None
|
||||
|
||||
@@ -42,7 +43,7 @@ def setup_db(tmp_path):
|
||||
|
||||
yield
|
||||
|
||||
if hasattr(db_module._local, 'connection') and db_module._local.connection:
|
||||
if hasattr(db_module._local, "connection") and db_module._local.connection:
|
||||
db_module._local.connection.close()
|
||||
db_module._local.connection = None
|
||||
db_module.DB_PATH = original_db_path
|
||||
@@ -56,7 +57,7 @@ def app(setup_db):
|
||||
from routes.controller import controller_bp
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['TESTING'] = True
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(controller_bp)
|
||||
|
||||
return app
|
||||
@@ -72,13 +73,14 @@ def client(app):
|
||||
def sample_agent(setup_db):
|
||||
"""Create a sample agent in database."""
|
||||
from utils.database import create_agent
|
||||
|
||||
agent_id = create_agent(
|
||||
name='test-sensor',
|
||||
base_url='http://192.168.1.50:8020',
|
||||
api_key='test-key',
|
||||
description='Test sensor node',
|
||||
capabilities={'adsb': True, 'wifi': True},
|
||||
gps_coords={'lat': 40.7128, 'lon': -74.0060}
|
||||
name="test-sensor",
|
||||
base_url="http://192.168.1.50:8020",
|
||||
api_key="test-key",
|
||||
description="Test sensor node",
|
||||
capabilities={"adsb": True, "wifi": True},
|
||||
gps_coords={"lat": 40.7128, "lon": -74.0060},
|
||||
)
|
||||
return agent_id
|
||||
|
||||
@@ -87,125 +89,125 @@ def sample_agent(setup_db):
|
||||
# Agent CRUD Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestAgentCRUD:
|
||||
"""Tests for agent CRUD operations."""
|
||||
|
||||
def test_list_agents_empty(self, client):
|
||||
"""GET /controller/agents should return empty list initially."""
|
||||
response = client.get('/controller/agents')
|
||||
response = client.get("/controller/agents")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['agents'] == []
|
||||
assert data['count'] == 0
|
||||
assert data["status"] == "success"
|
||||
assert data["agents"] == []
|
||||
assert data["count"] == 0
|
||||
|
||||
def test_register_agent_success(self, client):
|
||||
"""POST /controller/agents should register new agent."""
|
||||
with patch('routes.controller.AgentClient') as MockClient:
|
||||
with patch("routes.controller.AgentClient") as MockClient:
|
||||
# Mock successful capability fetch
|
||||
mock_instance = Mock()
|
||||
mock_instance.get_capabilities.return_value = {
|
||||
'modes': {'adsb': True, 'wifi': True},
|
||||
'devices': [{'name': 'RTL-SDR'}]
|
||||
"modes": {"adsb": True, "wifi": True},
|
||||
"devices": [{"name": "RTL-SDR"}],
|
||||
}
|
||||
MockClient.return_value = mock_instance
|
||||
|
||||
response = client.post('/controller/agents',
|
||||
response = client.post(
|
||||
"/controller/agents",
|
||||
json={
|
||||
'name': 'new-sensor',
|
||||
'base_url': 'http://192.168.1.51:8020',
|
||||
'api_key': 'secret123',
|
||||
'description': 'New sensor node'
|
||||
"name": "new-sensor",
|
||||
"base_url": "http://192.168.1.51:8020",
|
||||
"api_key": "secret123",
|
||||
"description": "New sensor node",
|
||||
},
|
||||
content_type='application/json'
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['agent']['name'] == 'new-sensor'
|
||||
assert data["status"] == "success"
|
||||
assert data["agent"]["name"] == "new-sensor"
|
||||
|
||||
def test_register_agent_missing_name(self, client):
|
||||
"""POST /controller/agents should reject missing name."""
|
||||
response = client.post('/controller/agents',
|
||||
json={'base_url': 'http://localhost:8020'},
|
||||
content_type='application/json'
|
||||
response = client.post(
|
||||
"/controller/agents", json={"base_url": "http://localhost:8020"}, content_type="application/json"
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert 'name is required' in data['message']
|
||||
assert "name is required" in data["message"]
|
||||
|
||||
def test_register_agent_missing_url(self, client):
|
||||
"""POST /controller/agents should reject missing URL."""
|
||||
response = client.post('/controller/agents',
|
||||
json={'name': 'test-sensor'},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = client.post("/controller/agents", json={"name": "test-sensor"}, content_type="application/json")
|
||||
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert 'Base URL is required' in data['message']
|
||||
assert "Base URL is required" in data["message"]
|
||||
|
||||
def test_register_agent_duplicate_name(self, client, sample_agent):
|
||||
"""POST /controller/agents should reject duplicate name."""
|
||||
response = client.post('/controller/agents',
|
||||
response = client.post(
|
||||
"/controller/agents",
|
||||
json={
|
||||
'name': 'test-sensor', # Same as sample_agent
|
||||
'base_url': 'http://192.168.1.60:8020'
|
||||
"name": "test-sensor", # Same as sample_agent
|
||||
"base_url": "http://192.168.1.60:8020",
|
||||
},
|
||||
content_type='application/json'
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
data = json.loads(response.data)
|
||||
assert 'already exists' in data['message']
|
||||
assert "already exists" in data["message"]
|
||||
|
||||
def test_list_agents_with_agents(self, client, sample_agent):
|
||||
"""GET /controller/agents should return registered agents."""
|
||||
response = client.get('/controller/agents')
|
||||
response = client.get("/controller/agents")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['count'] >= 1
|
||||
assert data["count"] >= 1
|
||||
|
||||
names = [a['name'] for a in data['agents']]
|
||||
assert 'test-sensor' in names
|
||||
names = [a["name"] for a in data["agents"]]
|
||||
assert "test-sensor" in names
|
||||
|
||||
def test_get_agent_detail(self, client, sample_agent):
|
||||
"""GET /controller/agents/<id> should return agent details."""
|
||||
response = client.get(f'/controller/agents/{sample_agent}')
|
||||
response = client.get(f"/controller/agents/{sample_agent}")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['agent']['name'] == 'test-sensor'
|
||||
assert data['agent']['capabilities']['adsb'] is True
|
||||
assert data["status"] == "success"
|
||||
assert data["agent"]["name"] == "test-sensor"
|
||||
assert data["agent"]["capabilities"]["adsb"] is True
|
||||
|
||||
def test_get_agent_not_found(self, client):
|
||||
"""GET /controller/agents/<id> should return 404 for missing agent."""
|
||||
response = client.get('/controller/agents/99999')
|
||||
response = client.get("/controller/agents/99999")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_update_agent(self, client, sample_agent):
|
||||
"""PATCH /controller/agents/<id> should update agent."""
|
||||
response = client.patch(f'/controller/agents/{sample_agent}',
|
||||
json={'description': 'Updated description'},
|
||||
content_type='application/json'
|
||||
response = client.patch(
|
||||
f"/controller/agents/{sample_agent}",
|
||||
json={"description": "Updated description"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['agent']['description'] == 'Updated description'
|
||||
assert data["agent"]["description"] == "Updated description"
|
||||
|
||||
def test_delete_agent(self, client, sample_agent):
|
||||
"""DELETE /controller/agents/<id> should remove agent."""
|
||||
response = client.delete(f'/controller/agents/{sample_agent}')
|
||||
response = client.delete(f"/controller/agents/{sample_agent}")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify deleted
|
||||
response = client.get(f'/controller/agents/{sample_agent}')
|
||||
response = client.get(f"/controller/agents/{sample_agent}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@@ -213,345 +215,325 @@ class TestAgentCRUD:
|
||||
# Proxy Operation Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestProxyOperations:
|
||||
"""Tests for proxying operations to agents."""
|
||||
|
||||
def test_proxy_start_mode(self, client, sample_agent):
|
||||
"""POST /controller/agents/<id>/<mode>/start should proxy to agent."""
|
||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||
with patch("routes.controller.create_client_from_agent") as mock_create:
|
||||
mock_client = Mock()
|
||||
mock_client.start_mode.return_value = {'status': 'started', 'mode': 'adsb'}
|
||||
mock_client.start_mode.return_value = {"status": "started", "mode": "adsb"}
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
response = client.post(
|
||||
f'/controller/agents/{sample_agent}/adsb/start',
|
||||
json={'device_index': 0},
|
||||
content_type='application/json'
|
||||
f"/controller/agents/{sample_agent}/adsb/start",
|
||||
json={"device_index": 0},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['mode'] == 'adsb'
|
||||
assert data["status"] == "success"
|
||||
assert data["mode"] == "adsb"
|
||||
|
||||
mock_client.start_mode.assert_called_once_with('adsb', {'device_index': 0})
|
||||
mock_client.start_mode.assert_called_once_with("adsb", {"device_index": 0})
|
||||
|
||||
def test_proxy_stop_mode(self, client, sample_agent):
|
||||
"""POST /controller/agents/<id>/<mode>/stop should proxy to agent."""
|
||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||
with patch("routes.controller.create_client_from_agent") as mock_create:
|
||||
mock_client = Mock()
|
||||
mock_client.stop_mode.return_value = {'status': 'stopped'}
|
||||
mock_client.stop_mode.return_value = {"status": "stopped"}
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
response = client.post(
|
||||
f'/controller/agents/{sample_agent}/wifi/stop',
|
||||
content_type='application/json'
|
||||
)
|
||||
response = client.post(f"/controller/agents/{sample_agent}/wifi/stop", content_type="application/json")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data["status"] == "success"
|
||||
|
||||
def test_proxy_get_mode_data(self, client, sample_agent):
|
||||
"""GET /controller/agents/<id>/<mode>/data should return data."""
|
||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||
with patch("routes.controller.create_client_from_agent") as mock_create:
|
||||
mock_client = Mock()
|
||||
mock_client.get_mode_data.return_value = {
|
||||
'mode': 'adsb',
|
||||
'data': [{'icao': 'ABC123'}]
|
||||
}
|
||||
mock_client.get_mode_data.return_value = {"mode": "adsb", "data": [{"icao": "ABC123"}]}
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
response = client.get(f'/controller/agents/{sample_agent}/adsb/data')
|
||||
response = client.get(f"/controller/agents/{sample_agent}/adsb/data")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'agent_name' in data
|
||||
assert data['agent_name'] == 'test-sensor'
|
||||
assert data["status"] == "success"
|
||||
assert "agent_name" in data
|
||||
assert data["agent_name"] == "test-sensor"
|
||||
|
||||
def test_proxy_agent_not_found(self, client):
|
||||
"""Proxy operations should return 404 for missing agent."""
|
||||
response = client.post('/controller/agents/99999/adsb/start')
|
||||
response = client.post("/controller/agents/99999/adsb/start")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_proxy_connection_error(self, client, sample_agent):
|
||||
"""Proxy should return 503 when agent unreachable."""
|
||||
from utils.agent_client import AgentConnectionError
|
||||
|
||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||
with patch("routes.controller.create_client_from_agent") as mock_create:
|
||||
mock_client = Mock()
|
||||
mock_client.start_mode.side_effect = AgentConnectionError("Connection refused")
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
response = client.post(
|
||||
f'/controller/agents/{sample_agent}/adsb/start',
|
||||
json={},
|
||||
content_type='application/json'
|
||||
f"/controller/agents/{sample_agent}/adsb/start", json={}, content_type="application/json"
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
data = json.loads(response.data)
|
||||
assert 'Cannot connect' in data['message']
|
||||
assert "Cannot connect" in data["message"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Push Data Ingestion Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestPushIngestion:
|
||||
"""Tests for push data ingestion endpoint."""
|
||||
|
||||
def test_ingest_success(self, client, sample_agent):
|
||||
"""POST /controller/api/ingest should store payload."""
|
||||
payload = {
|
||||
'agent_name': 'test-sensor',
|
||||
'scan_type': 'adsb',
|
||||
'interface': 'rtlsdr0',
|
||||
'payload': {
|
||||
'aircraft': [{'icao': 'ABC123', 'altitude': 35000}]
|
||||
}
|
||||
"agent_name": "test-sensor",
|
||||
"scan_type": "adsb",
|
||||
"interface": "rtlsdr0",
|
||||
"payload": {"aircraft": [{"icao": "ABC123", "altitude": 35000}]},
|
||||
}
|
||||
|
||||
response = client.post('/controller/api/ingest',
|
||||
json=payload,
|
||||
headers={'X-API-Key': 'test-key'},
|
||||
content_type='application/json'
|
||||
response = client.post(
|
||||
"/controller/api/ingest", json=payload, headers={"X-API-Key": "test-key"}, content_type="application/json"
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'accepted'
|
||||
assert 'payload_id' in data
|
||||
assert data["status"] == "accepted"
|
||||
assert "payload_id" in data
|
||||
|
||||
def test_ingest_unknown_agent(self, client):
|
||||
"""POST /controller/api/ingest should reject unknown agent."""
|
||||
payload = {
|
||||
'agent_name': 'nonexistent-sensor',
|
||||
'scan_type': 'adsb',
|
||||
'payload': {}
|
||||
}
|
||||
payload = {"agent_name": "nonexistent-sensor", "scan_type": "adsb", "payload": {}}
|
||||
|
||||
response = client.post('/controller/api/ingest',
|
||||
json=payload,
|
||||
content_type='application/json'
|
||||
)
|
||||
response = client.post("/controller/api/ingest", json=payload, content_type="application/json")
|
||||
|
||||
assert response.status_code == 401
|
||||
data = json.loads(response.data)
|
||||
assert 'Unknown agent' in data['message']
|
||||
assert "Unknown agent" in data["message"]
|
||||
|
||||
def test_ingest_invalid_api_key(self, client, sample_agent):
|
||||
"""POST /controller/api/ingest should reject invalid API key."""
|
||||
payload = {
|
||||
'agent_name': 'test-sensor',
|
||||
'scan_type': 'adsb',
|
||||
'payload': {}
|
||||
}
|
||||
payload = {"agent_name": "test-sensor", "scan_type": "adsb", "payload": {}}
|
||||
|
||||
response = client.post('/controller/api/ingest',
|
||||
json=payload,
|
||||
headers={'X-API-Key': 'wrong-key'},
|
||||
content_type='application/json'
|
||||
response = client.post(
|
||||
"/controller/api/ingest", json=payload, headers={"X-API-Key": "wrong-key"}, content_type="application/json"
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
data = json.loads(response.data)
|
||||
assert 'Invalid API key' in data['message']
|
||||
assert "Invalid API key" in data["message"]
|
||||
|
||||
def test_ingest_missing_agent_name(self, client):
|
||||
"""POST /controller/api/ingest should require agent_name."""
|
||||
response = client.post('/controller/api/ingest',
|
||||
json={'scan_type': 'adsb', 'payload': {}},
|
||||
content_type='application/json'
|
||||
response = client.post(
|
||||
"/controller/api/ingest", json={"scan_type": "adsb", "payload": {}}, content_type="application/json"
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert 'agent_name required' in data['message']
|
||||
assert "agent_name required" in data["message"]
|
||||
|
||||
def test_get_payloads(self, client, sample_agent):
|
||||
"""GET /controller/api/payloads should return stored payloads."""
|
||||
# First ingest some data
|
||||
for i in range(3):
|
||||
client.post('/controller/api/ingest',
|
||||
client.post(
|
||||
"/controller/api/ingest",
|
||||
json={
|
||||
'agent_name': 'test-sensor',
|
||||
'scan_type': 'adsb',
|
||||
'payload': {'aircraft': [{'icao': f'TEST{i}'}]}
|
||||
"agent_name": "test-sensor",
|
||||
"scan_type": "adsb",
|
||||
"payload": {"aircraft": [{"icao": f"TEST{i}"}]},
|
||||
},
|
||||
headers={'X-API-Key': 'test-key'},
|
||||
content_type='application/json'
|
||||
headers={"X-API-Key": "test-key"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = client.get(f'/controller/api/payloads?agent_id={sample_agent}')
|
||||
response = client.get(f"/controller/api/payloads?agent_id={sample_agent}")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['count'] == 3
|
||||
assert data["count"] == 3
|
||||
|
||||
def test_get_payloads_filter_by_type(self, client, sample_agent):
|
||||
"""GET /controller/api/payloads should filter by scan_type."""
|
||||
# Ingest mixed data
|
||||
client.post('/controller/api/ingest',
|
||||
json={'agent_name': 'test-sensor', 'scan_type': 'adsb', 'payload': {}},
|
||||
headers={'X-API-Key': 'test-key'},
|
||||
content_type='application/json'
|
||||
client.post(
|
||||
"/controller/api/ingest",
|
||||
json={"agent_name": "test-sensor", "scan_type": "adsb", "payload": {}},
|
||||
headers={"X-API-Key": "test-key"},
|
||||
content_type="application/json",
|
||||
)
|
||||
client.post('/controller/api/ingest',
|
||||
json={'agent_name': 'test-sensor', 'scan_type': 'wifi', 'payload': {}},
|
||||
headers={'X-API-Key': 'test-key'},
|
||||
content_type='application/json'
|
||||
client.post(
|
||||
"/controller/api/ingest",
|
||||
json={"agent_name": "test-sensor", "scan_type": "wifi", "payload": {}},
|
||||
headers={"X-API-Key": "test-key"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = client.get('/controller/api/payloads?scan_type=adsb')
|
||||
response = client.get("/controller/api/payloads?scan_type=adsb")
|
||||
data = json.loads(response.data)
|
||||
|
||||
assert all(p['scan_type'] == 'adsb' for p in data['payloads'])
|
||||
assert all(p["scan_type"] == "adsb" for p in data["payloads"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Location Estimation Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestLocationEstimation:
|
||||
"""Tests for device location estimation (trilateration)."""
|
||||
|
||||
def test_add_observation(self, client):
|
||||
"""POST /controller/api/location/observe should accept observation."""
|
||||
response = client.post('/controller/api/location/observe',
|
||||
response = client.post(
|
||||
"/controller/api/location/observe",
|
||||
json={
|
||||
'device_id': 'AA:BB:CC:DD:EE:FF',
|
||||
'agent_name': 'sensor-1',
|
||||
'agent_lat': 40.7128,
|
||||
'agent_lon': -74.0060,
|
||||
'rssi': -55
|
||||
"device_id": "AA:BB:CC:DD:EE:FF",
|
||||
"agent_name": "sensor-1",
|
||||
"agent_lat": 40.7128,
|
||||
"agent_lon": -74.0060,
|
||||
"rssi": -55,
|
||||
},
|
||||
content_type='application/json'
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['device_id'] == 'AA:BB:CC:DD:EE:FF'
|
||||
assert data["status"] == "success"
|
||||
assert data["device_id"] == "AA:BB:CC:DD:EE:FF"
|
||||
|
||||
def test_add_observation_missing_fields(self, client):
|
||||
"""POST /controller/api/location/observe should require all fields."""
|
||||
response = client.post('/controller/api/location/observe',
|
||||
response = client.post(
|
||||
"/controller/api/location/observe",
|
||||
json={
|
||||
'device_id': 'AA:BB:CC:DD:EE:FF',
|
||||
'rssi': -55
|
||||
"device_id": "AA:BB:CC:DD:EE:FF",
|
||||
"rssi": -55,
|
||||
# Missing agent_name, agent_lat, agent_lon
|
||||
},
|
||||
content_type='application/json'
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_estimate_location(self, client):
|
||||
"""POST /controller/api/location/estimate should compute location."""
|
||||
response = client.post('/controller/api/location/estimate',
|
||||
response = client.post(
|
||||
"/controller/api/location/estimate",
|
||||
json={
|
||||
'observations': [
|
||||
{'agent_lat': 40.7128, 'agent_lon': -74.0060, 'rssi': -55, 'agent_name': 'node-1'},
|
||||
{'agent_lat': 40.7135, 'agent_lon': -74.0055, 'rssi': -70, 'agent_name': 'node-2'},
|
||||
{'agent_lat': 40.7120, 'agent_lon': -74.0050, 'rssi': -62, 'agent_name': 'node-3'}
|
||||
"observations": [
|
||||
{"agent_lat": 40.7128, "agent_lon": -74.0060, "rssi": -55, "agent_name": "node-1"},
|
||||
{"agent_lat": 40.7135, "agent_lon": -74.0055, "rssi": -70, "agent_name": "node-2"},
|
||||
{"agent_lat": 40.7120, "agent_lon": -74.0050, "rssi": -62, "agent_name": "node-3"},
|
||||
],
|
||||
'environment': 'outdoor'
|
||||
"environment": "outdoor",
|
||||
},
|
||||
content_type='application/json'
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
# Should have computed a location
|
||||
if data['location']:
|
||||
assert 'lat' in data['location']
|
||||
assert 'lon' in data['location']
|
||||
if data["location"]:
|
||||
assert "latitude" in data["location"]
|
||||
assert "longitude" in data["location"]
|
||||
|
||||
def test_estimate_location_insufficient_data(self, client):
|
||||
"""Estimation should require at least 2 observations."""
|
||||
response = client.post('/controller/api/location/estimate',
|
||||
json={
|
||||
'observations': [
|
||||
{'agent_lat': 40.7128, 'agent_lon': -74.0060, 'rssi': -55, 'agent_name': 'node-1'}
|
||||
]
|
||||
},
|
||||
content_type='application/json'
|
||||
response = client.post(
|
||||
"/controller/api/location/estimate",
|
||||
json={"observations": [{"agent_lat": 40.7128, "agent_lon": -74.0060, "rssi": -55, "agent_name": "node-1"}]},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert 'At least 2' in data['message']
|
||||
assert "At least 2" in data["message"]
|
||||
|
||||
def test_get_device_location_not_found(self, client):
|
||||
"""GET /controller/api/location/<device_id> returns not_found for unknown device."""
|
||||
response = client.get('/controller/api/location/unknown-device')
|
||||
response = client.get("/controller/api/location/unknown-device")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'not_found'
|
||||
assert data['location'] is None
|
||||
assert data["status"] == "not_found"
|
||||
assert data["location"] is None
|
||||
|
||||
def test_get_all_locations(self, client):
|
||||
"""GET /controller/api/location/all should return all estimates."""
|
||||
response = client.get('/controller/api/location/all')
|
||||
response = client.get("/controller/api/location/all")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'devices' in data
|
||||
assert data["status"] == "success"
|
||||
assert "devices" in data
|
||||
|
||||
def test_get_devices_near(self, client):
|
||||
"""GET /controller/api/location/near should find nearby devices."""
|
||||
response = client.get(
|
||||
'/controller/api/location/near',
|
||||
query_string={'lat': 40.7128, 'lon': -74.0060, 'radius': 100}
|
||||
"/controller/api/location/near", query_string={"lat": 40.7128, "lon": -74.0060, "radius": 100}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['center']['lat'] == 40.7128
|
||||
assert data["status"] == "success"
|
||||
assert data["center"]["lat"] == 40.7128
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent Refresh Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestAgentRefresh:
|
||||
"""Tests for agent refresh operations."""
|
||||
|
||||
def test_refresh_agent_success(self, client, sample_agent):
|
||||
"""POST /controller/agents/<id>/refresh should update metadata."""
|
||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||
with patch("routes.controller.create_client_from_agent") as mock_create:
|
||||
mock_client = Mock()
|
||||
mock_client.refresh_metadata.return_value = {
|
||||
'healthy': True,
|
||||
'capabilities': {
|
||||
'modes': {'adsb': True, 'wifi': True, 'bluetooth': True},
|
||||
'devices': [{'name': 'RTL-SDR V3'}]
|
||||
"healthy": True,
|
||||
"capabilities": {
|
||||
"modes": {"adsb": True, "wifi": True, "bluetooth": True},
|
||||
"devices": [{"name": "RTL-SDR V3"}],
|
||||
},
|
||||
'status': {'running_modes': ['adsb']},
|
||||
'config': {}
|
||||
"status": {"running_modes": ["adsb"]},
|
||||
"config": {},
|
||||
}
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
response = client.post(f'/controller/agents/{sample_agent}/refresh')
|
||||
response = client.post(f"/controller/agents/{sample_agent}/refresh")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['metadata']['healthy'] is True
|
||||
assert data["status"] == "success"
|
||||
assert data["metadata"]["healthy"] is True
|
||||
|
||||
def test_refresh_agent_unreachable(self, client, sample_agent):
|
||||
"""POST /controller/agents/<id>/refresh should return 503 if unreachable."""
|
||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||
with patch("routes.controller.create_client_from_agent") as mock_create:
|
||||
mock_client = Mock()
|
||||
mock_client.refresh_metadata.return_value = {'healthy': False}
|
||||
mock_client.refresh_metadata.return_value = {"healthy": False}
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
response = client.post(f'/controller/agents/{sample_agent}/refresh')
|
||||
response = client.post(f"/controller/agents/{sample_agent}/refresh")
|
||||
|
||||
assert response.status_code == 503
|
||||
|
||||
@@ -560,6 +542,7 @@ class TestAgentRefresh:
|
||||
# SSE Stream Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestSSEStream:
|
||||
"""Tests for SSE streaming endpoint."""
|
||||
|
||||
@@ -567,5 +550,65 @@ class TestSSEStream:
|
||||
"""GET /controller/stream/all should exist and return SSE."""
|
||||
# Just verify the endpoint is accessible
|
||||
# Full SSE testing requires more complex setup
|
||||
response = client.get('/controller/stream/all')
|
||||
assert response.content_type == 'text/event-stream'
|
||||
response = client.get("/controller/stream/all")
|
||||
assert response.mimetype == "text/event-stream"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Generic Proxy Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGenericProxy:
|
||||
"""Tests for the allowlisted agent passthrough proxy."""
|
||||
|
||||
def _mock_agent(self):
|
||||
return {"id": 1, "name": "node-1", "base_url": "http://10.0.0.2:5000", "api_key": None}
|
||||
|
||||
def test_proxies_allowlisted_get(self, client):
|
||||
with (
|
||||
patch("routes.controller.get_agent", return_value=self._mock_agent()),
|
||||
patch("routes.controller.create_client_from_agent") as mock_create,
|
||||
):
|
||||
mock_create.return_value.get.return_value = [{"mac": "AA:BB"}]
|
||||
resp = client.get("/controller/agents/1/proxy/wifi/v2/clients?bssid=AA:BB")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["status"] == "success"
|
||||
assert data["result"] == [{"mac": "AA:BB"}]
|
||||
mock_create.return_value.get.assert_called_once_with("/wifi/v2/clients", params={"bssid": "AA:BB"})
|
||||
|
||||
def test_rejects_non_allowlisted_path(self, client):
|
||||
with patch("routes.controller.get_agent", return_value=self._mock_agent()):
|
||||
resp = client.get("/controller/agents/1/proxy/settings/secrets")
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_unknown_agent_404(self, client):
|
||||
with patch("routes.controller.get_agent", return_value=None):
|
||||
resp = client.get("/controller/agents/99/proxy/wifi/v2/clients")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_rejects_dot_segment_traversal(self, client):
|
||||
# Werkzeug may normalize or reject ".." segments before the view runs,
|
||||
# so the view might never be reached (404). What matters is that the
|
||||
# request does NOT succeed (200) and the agent is never contacted.
|
||||
with (
|
||||
patch("routes.controller.get_agent", return_value=self._mock_agent()),
|
||||
patch("routes.controller.create_client_from_agent") as mock_create,
|
||||
):
|
||||
resp = client.get("/controller/agents/1/proxy/wifi/v2/../../settings/secrets")
|
||||
# Any status except 200/502 is safe; the agent must not have been called.
|
||||
assert resp.status_code not in (200, 502)
|
||||
mock_create.return_value.get.assert_not_called()
|
||||
|
||||
def test_rejects_encoded_traversal(self, client):
|
||||
# Percent-encoded dots (%2e%2e) — Werkzeug or the test client may
|
||||
# decode and normalize these before routing (404), or our canonicality
|
||||
# check catches them (403). Either way the agent must not be contacted.
|
||||
with (
|
||||
patch("routes.controller.get_agent", return_value=self._mock_agent()),
|
||||
patch("routes.controller.create_client_from_agent") as mock_create,
|
||||
):
|
||||
resp = client.get("/controller/agents/1/proxy/wifi/v2/%2e%2e/%2e%2e/settings/secrets")
|
||||
assert resp.status_code in (403, 404)
|
||||
mock_create.return_value.get.assert_not_called()
|
||||
|
||||
+216
-244
@@ -7,7 +7,7 @@ import sys
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from utils.constants import (
|
||||
DEAUTH_ALERT_THRESHOLD,
|
||||
@@ -30,16 +30,16 @@ class TestDeauthPacketInfo:
|
||||
"""Test basic creation of packet info."""
|
||||
pkt = DeauthPacketInfo(
|
||||
timestamp=1234567890.0,
|
||||
frame_type='deauth',
|
||||
src_mac='AA:BB:CC:DD:EE:FF',
|
||||
dst_mac='11:22:33:44:55:66',
|
||||
bssid='AA:BB:CC:DD:EE:FF',
|
||||
frame_type="deauth",
|
||||
src_mac="AA:BB:CC:DD:EE:FF",
|
||||
dst_mac="11:22:33:44:55:66",
|
||||
bssid="AA:BB:CC:DD:EE:FF",
|
||||
reason_code=7,
|
||||
signal_dbm=-45,
|
||||
)
|
||||
|
||||
assert pkt.frame_type == 'deauth'
|
||||
assert pkt.src_mac == 'AA:BB:CC:DD:EE:FF'
|
||||
assert pkt.frame_type == "deauth"
|
||||
assert pkt.src_mac == "AA:BB:CC:DD:EE:FF"
|
||||
assert pkt.reason_code == 7
|
||||
assert pkt.signal_dbm == -45
|
||||
|
||||
@@ -53,10 +53,10 @@ class TestDeauthTracker:
|
||||
|
||||
pkt1 = DeauthPacketInfo(
|
||||
timestamp=100.0,
|
||||
frame_type='deauth',
|
||||
src_mac='AA:BB:CC:DD:EE:FF',
|
||||
dst_mac='11:22:33:44:55:66',
|
||||
bssid='AA:BB:CC:DD:EE:FF',
|
||||
frame_type="deauth",
|
||||
src_mac="AA:BB:CC:DD:EE:FF",
|
||||
dst_mac="11:22:33:44:55:66",
|
||||
bssid="AA:BB:CC:DD:EE:FF",
|
||||
reason_code=7,
|
||||
)
|
||||
tracker.add_packet(pkt1)
|
||||
@@ -72,10 +72,10 @@ class TestDeauthTracker:
|
||||
for i in range(5):
|
||||
pkt = DeauthPacketInfo(
|
||||
timestamp=100.0 + i,
|
||||
frame_type='deauth',
|
||||
src_mac='AA:BB:CC:DD:EE:FF',
|
||||
dst_mac='11:22:33:44:55:66',
|
||||
bssid='AA:BB:CC:DD:EE:FF',
|
||||
frame_type="deauth",
|
||||
src_mac="AA:BB:CC:DD:EE:FF",
|
||||
dst_mac="11:22:33:44:55:66",
|
||||
bssid="AA:BB:CC:DD:EE:FF",
|
||||
reason_code=7,
|
||||
)
|
||||
tracker.add_packet(pkt)
|
||||
@@ -90,25 +90,29 @@ class TestDeauthTracker:
|
||||
now = time.time()
|
||||
|
||||
# Add old packet
|
||||
tracker.add_packet(DeauthPacketInfo(
|
||||
timestamp=now - 10,
|
||||
frame_type='deauth',
|
||||
src_mac='AA:BB:CC:DD:EE:FF',
|
||||
dst_mac='11:22:33:44:55:66',
|
||||
bssid='AA:BB:CC:DD:EE:FF',
|
||||
reason_code=7,
|
||||
))
|
||||
tracker.add_packet(
|
||||
DeauthPacketInfo(
|
||||
timestamp=now - 10,
|
||||
frame_type="deauth",
|
||||
src_mac="AA:BB:CC:DD:EE:FF",
|
||||
dst_mac="11:22:33:44:55:66",
|
||||
bssid="AA:BB:CC:DD:EE:FF",
|
||||
reason_code=7,
|
||||
)
|
||||
)
|
||||
|
||||
# Add recent packets
|
||||
for i in range(3):
|
||||
tracker.add_packet(DeauthPacketInfo(
|
||||
timestamp=now - i,
|
||||
frame_type='deauth',
|
||||
src_mac='AA:BB:CC:DD:EE:FF',
|
||||
dst_mac='11:22:33:44:55:66',
|
||||
bssid='AA:BB:CC:DD:EE:FF',
|
||||
reason_code=7,
|
||||
))
|
||||
tracker.add_packet(
|
||||
DeauthPacketInfo(
|
||||
timestamp=now - i,
|
||||
frame_type="deauth",
|
||||
src_mac="AA:BB:CC:DD:EE:FF",
|
||||
dst_mac="11:22:33:44:55:66",
|
||||
bssid="AA:BB:CC:DD:EE:FF",
|
||||
reason_code=7,
|
||||
)
|
||||
)
|
||||
|
||||
# 5-second window should only include the 3 recent packets
|
||||
in_window = tracker.get_packets_in_window(5.0)
|
||||
@@ -120,24 +124,28 @@ class TestDeauthTracker:
|
||||
now = time.time()
|
||||
|
||||
# Add old packet
|
||||
tracker.add_packet(DeauthPacketInfo(
|
||||
timestamp=now - 20,
|
||||
frame_type='deauth',
|
||||
src_mac='AA:BB:CC:DD:EE:FF',
|
||||
dst_mac='11:22:33:44:55:66',
|
||||
bssid='AA:BB:CC:DD:EE:FF',
|
||||
reason_code=7,
|
||||
))
|
||||
tracker.add_packet(
|
||||
DeauthPacketInfo(
|
||||
timestamp=now - 20,
|
||||
frame_type="deauth",
|
||||
src_mac="AA:BB:CC:DD:EE:FF",
|
||||
dst_mac="11:22:33:44:55:66",
|
||||
bssid="AA:BB:CC:DD:EE:FF",
|
||||
reason_code=7,
|
||||
)
|
||||
)
|
||||
|
||||
# Add recent packet
|
||||
tracker.add_packet(DeauthPacketInfo(
|
||||
timestamp=now,
|
||||
frame_type='deauth',
|
||||
src_mac='AA:BB:CC:DD:EE:FF',
|
||||
dst_mac='11:22:33:44:55:66',
|
||||
bssid='AA:BB:CC:DD:EE:FF',
|
||||
reason_code=7,
|
||||
))
|
||||
tracker.add_packet(
|
||||
DeauthPacketInfo(
|
||||
timestamp=now,
|
||||
frame_type="deauth",
|
||||
src_mac="AA:BB:CC:DD:EE:FF",
|
||||
dst_mac="11:22:33:44:55:66",
|
||||
bssid="AA:BB:CC:DD:EE:FF",
|
||||
reason_code=7,
|
||||
)
|
||||
)
|
||||
|
||||
tracker.alert_sent = True
|
||||
|
||||
@@ -152,14 +160,16 @@ class TestDeauthTracker:
|
||||
tracker = DeauthTracker()
|
||||
now = time.time()
|
||||
|
||||
tracker.add_packet(DeauthPacketInfo(
|
||||
timestamp=now - 100, # Very old
|
||||
frame_type='deauth',
|
||||
src_mac='AA:BB:CC:DD:EE:FF',
|
||||
dst_mac='11:22:33:44:55:66',
|
||||
bssid='AA:BB:CC:DD:EE:FF',
|
||||
reason_code=7,
|
||||
))
|
||||
tracker.add_packet(
|
||||
DeauthPacketInfo(
|
||||
timestamp=now - 100, # Very old
|
||||
frame_type="deauth",
|
||||
src_mac="AA:BB:CC:DD:EE:FF",
|
||||
dst_mac="11:22:33:44:55:66",
|
||||
bssid="AA:BB:CC:DD:EE:FF",
|
||||
reason_code=7,
|
||||
)
|
||||
)
|
||||
|
||||
tracker.alert_sent = True
|
||||
|
||||
@@ -176,41 +186,41 @@ class TestDeauthAlert:
|
||||
def test_to_dict(self):
|
||||
"""Test conversion to dictionary."""
|
||||
alert = DeauthAlert(
|
||||
id='deauth-123-1',
|
||||
id="deauth-123-1",
|
||||
timestamp=1234567890.0,
|
||||
severity='high',
|
||||
attacker_mac='AA:BB:CC:DD:EE:FF',
|
||||
attacker_vendor='Unknown',
|
||||
severity="high",
|
||||
attacker_mac="AA:BB:CC:DD:EE:FF",
|
||||
attacker_vendor="Unknown",
|
||||
attacker_signal_dbm=-45,
|
||||
is_spoofed_ap=True,
|
||||
target_mac='11:22:33:44:55:66',
|
||||
target_vendor='Apple',
|
||||
target_type='client',
|
||||
target_mac="11:22:33:44:55:66",
|
||||
target_vendor="Apple",
|
||||
target_type="client",
|
||||
target_known_from_scan=True,
|
||||
ap_bssid='AA:BB:CC:DD:EE:FF',
|
||||
ap_essid='TestNetwork',
|
||||
ap_bssid="AA:BB:CC:DD:EE:FF",
|
||||
ap_essid="TestNetwork",
|
||||
ap_channel=6,
|
||||
frame_type='deauth',
|
||||
frame_type="deauth",
|
||||
reason_code=7,
|
||||
reason_text='Class 3 frame received from nonassociated STA',
|
||||
reason_text="Class 3 frame received from nonassociated STA",
|
||||
packet_count=50,
|
||||
window_seconds=5.0,
|
||||
packets_per_second=10.0,
|
||||
attack_type='targeted',
|
||||
description='Targeted deauth flood against known client',
|
||||
attack_type="targeted",
|
||||
description="Targeted deauth flood against known client",
|
||||
)
|
||||
|
||||
d = alert.to_dict()
|
||||
|
||||
assert d['id'] == 'deauth-123-1'
|
||||
assert d['type'] == 'deauth_alert'
|
||||
assert d['severity'] == 'high'
|
||||
assert d['attacker']['mac'] == 'AA:BB:CC:DD:EE:FF'
|
||||
assert d['attacker']['is_spoofed_ap'] is True
|
||||
assert d['target']['type'] == 'client'
|
||||
assert d['access_point']['essid'] == 'TestNetwork'
|
||||
assert d['attack_info']['packet_count'] == 50
|
||||
assert d['analysis']['attack_type'] == 'targeted'
|
||||
assert d["id"] == "deauth-123-1"
|
||||
assert d["type"] == "deauth_alert"
|
||||
assert d["severity"] == "high"
|
||||
assert d["attacker"]["mac"] == "AA:BB:CC:DD:EE:FF"
|
||||
assert d["attacker"]["is_spoofed_ap"] is True
|
||||
assert d["target"]["type"] == "client"
|
||||
assert d["access_point"]["essid"] == "TestNetwork"
|
||||
assert d["attack_info"]["packet_count"] == 50
|
||||
assert d["analysis"]["attack_type"] == "targeted"
|
||||
|
||||
|
||||
class TestDeauthDetector:
|
||||
@@ -220,11 +230,11 @@ class TestDeauthDetector:
|
||||
"""Test detector initialization."""
|
||||
callback = MagicMock()
|
||||
detector = DeauthDetector(
|
||||
interface='wlan0mon',
|
||||
interface="wlan0mon",
|
||||
event_callback=callback,
|
||||
)
|
||||
|
||||
assert detector.interface == 'wlan0mon'
|
||||
assert detector.interface == "wlan0mon"
|
||||
assert detector.event_callback == callback
|
||||
assert not detector.is_running
|
||||
|
||||
@@ -232,21 +242,21 @@ class TestDeauthDetector:
|
||||
"""Test stats property."""
|
||||
callback = MagicMock()
|
||||
detector = DeauthDetector(
|
||||
interface='wlan0mon',
|
||||
interface="wlan0mon",
|
||||
event_callback=callback,
|
||||
)
|
||||
|
||||
stats = detector.stats
|
||||
assert stats['is_running'] is False
|
||||
assert stats['interface'] == 'wlan0mon'
|
||||
assert stats['packets_captured'] == 0
|
||||
assert stats['alerts_generated'] == 0
|
||||
assert stats["is_running"] is False
|
||||
assert stats["interface"] == "wlan0mon"
|
||||
assert stats["packets_captured"] == 0
|
||||
assert stats["alerts_generated"] == 0
|
||||
|
||||
def test_get_alerts_empty(self):
|
||||
"""Test getting alerts when none exist."""
|
||||
callback = MagicMock()
|
||||
detector = DeauthDetector(
|
||||
interface='wlan0mon',
|
||||
interface="wlan0mon",
|
||||
event_callback=callback,
|
||||
)
|
||||
|
||||
@@ -257,13 +267,13 @@ class TestDeauthDetector:
|
||||
"""Test clearing alerts."""
|
||||
callback = MagicMock()
|
||||
detector = DeauthDetector(
|
||||
interface='wlan0mon',
|
||||
interface="wlan0mon",
|
||||
event_callback=callback,
|
||||
)
|
||||
|
||||
# Add a mock alert
|
||||
detector._alerts.append(MagicMock())
|
||||
detector._trackers[('A', 'B', 'C')] = DeauthTracker()
|
||||
detector._trackers[("A", "B", "C")] = DeauthTracker()
|
||||
detector._alert_counter = 5
|
||||
|
||||
detector.clear_alerts()
|
||||
@@ -272,158 +282,160 @@ class TestDeauthDetector:
|
||||
assert len(detector._trackers) == 0
|
||||
assert detector._alert_counter == 0
|
||||
|
||||
@patch('utils.wifi.deauth_detector.time.time')
|
||||
@patch("utils.wifi.deauth_detector.time.time")
|
||||
def test_generate_alert_severity_low(self, mock_time):
|
||||
"""Test alert generation with low severity."""
|
||||
mock_time.return_value = 1000.0
|
||||
|
||||
callback = MagicMock()
|
||||
detector = DeauthDetector(
|
||||
interface='wlan0mon',
|
||||
interface="wlan0mon",
|
||||
event_callback=callback,
|
||||
)
|
||||
|
||||
# Create packets just at threshold
|
||||
packets = []
|
||||
for i in range(DEAUTH_ALERT_THRESHOLD):
|
||||
packets.append(DeauthPacketInfo(
|
||||
timestamp=1000.0 - (DEAUTH_ALERT_THRESHOLD - 1 - i) * 0.1,
|
||||
frame_type='deauth',
|
||||
src_mac='AA:BB:CC:DD:EE:FF',
|
||||
dst_mac='11:22:33:44:55:66',
|
||||
bssid='99:88:77:66:55:44',
|
||||
reason_code=7,
|
||||
signal_dbm=-50,
|
||||
))
|
||||
packets.append(
|
||||
DeauthPacketInfo(
|
||||
timestamp=1000.0 - (DEAUTH_ALERT_THRESHOLD - 1 - i) * 0.1,
|
||||
frame_type="deauth",
|
||||
src_mac="AA:BB:CC:DD:EE:FF",
|
||||
dst_mac="11:22:33:44:55:66",
|
||||
bssid="99:88:77:66:55:44",
|
||||
reason_code=7,
|
||||
signal_dbm=-50,
|
||||
)
|
||||
)
|
||||
|
||||
alert = detector._generate_alert(
|
||||
tracker_key=('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44'),
|
||||
tracker_key=("AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66", "99:88:77:66:55:44"),
|
||||
packets=packets,
|
||||
packet_count=DEAUTH_ALERT_THRESHOLD,
|
||||
)
|
||||
|
||||
assert alert.severity == 'low'
|
||||
assert alert.severity == "low"
|
||||
assert alert.packet_count == DEAUTH_ALERT_THRESHOLD
|
||||
|
||||
@patch('utils.wifi.deauth_detector.time.time')
|
||||
@patch("utils.wifi.deauth_detector.time.time")
|
||||
def test_generate_alert_severity_high(self, mock_time):
|
||||
"""Test alert generation with high severity."""
|
||||
mock_time.return_value = 1000.0
|
||||
|
||||
callback = MagicMock()
|
||||
detector = DeauthDetector(
|
||||
interface='wlan0mon',
|
||||
interface="wlan0mon",
|
||||
event_callback=callback,
|
||||
)
|
||||
|
||||
# Create packets above critical threshold
|
||||
packets = []
|
||||
for i in range(DEAUTH_CRITICAL_THRESHOLD):
|
||||
packets.append(DeauthPacketInfo(
|
||||
timestamp=1000.0 - (DEAUTH_CRITICAL_THRESHOLD - 1 - i) * 0.1,
|
||||
frame_type='deauth',
|
||||
src_mac='AA:BB:CC:DD:EE:FF',
|
||||
dst_mac='11:22:33:44:55:66',
|
||||
bssid='99:88:77:66:55:44',
|
||||
reason_code=7,
|
||||
))
|
||||
packets.append(
|
||||
DeauthPacketInfo(
|
||||
timestamp=1000.0 - (DEAUTH_CRITICAL_THRESHOLD - 1 - i) * 0.1,
|
||||
frame_type="deauth",
|
||||
src_mac="AA:BB:CC:DD:EE:FF",
|
||||
dst_mac="11:22:33:44:55:66",
|
||||
bssid="99:88:77:66:55:44",
|
||||
reason_code=7,
|
||||
)
|
||||
)
|
||||
|
||||
alert = detector._generate_alert(
|
||||
tracker_key=('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44'),
|
||||
tracker_key=("AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66", "99:88:77:66:55:44"),
|
||||
packets=packets,
|
||||
packet_count=DEAUTH_CRITICAL_THRESHOLD,
|
||||
)
|
||||
|
||||
assert alert.severity == 'high'
|
||||
assert alert.severity == "high"
|
||||
|
||||
@patch('utils.wifi.deauth_detector.time.time')
|
||||
@patch("utils.wifi.deauth_detector.time.time")
|
||||
def test_generate_alert_broadcast_attack(self, mock_time):
|
||||
"""Test alert classification for broadcast attack."""
|
||||
mock_time.return_value = 1000.0
|
||||
|
||||
callback = MagicMock()
|
||||
detector = DeauthDetector(
|
||||
interface='wlan0mon',
|
||||
interface="wlan0mon",
|
||||
event_callback=callback,
|
||||
)
|
||||
|
||||
packets = [DeauthPacketInfo(
|
||||
timestamp=999.9,
|
||||
frame_type='deauth',
|
||||
src_mac='AA:BB:CC:DD:EE:FF',
|
||||
dst_mac='FF:FF:FF:FF:FF:FF', # Broadcast
|
||||
bssid='99:88:77:66:55:44',
|
||||
reason_code=7,
|
||||
)]
|
||||
packets = [
|
||||
DeauthPacketInfo(
|
||||
timestamp=999.9,
|
||||
frame_type="deauth",
|
||||
src_mac="AA:BB:CC:DD:EE:FF",
|
||||
dst_mac="FF:FF:FF:FF:FF:FF", # Broadcast
|
||||
bssid="99:88:77:66:55:44",
|
||||
reason_code=7,
|
||||
)
|
||||
]
|
||||
|
||||
alert = detector._generate_alert(
|
||||
tracker_key=('AA:BB:CC:DD:EE:FF', 'FF:FF:FF:FF:FF:FF', '99:88:77:66:55:44'),
|
||||
tracker_key=("AA:BB:CC:DD:EE:FF", "FF:FF:FF:FF:FF:FF", "99:88:77:66:55:44"),
|
||||
packets=packets,
|
||||
packet_count=10,
|
||||
)
|
||||
|
||||
assert alert.attack_type == 'broadcast'
|
||||
assert alert.target_type == 'broadcast'
|
||||
assert 'all clients' in alert.description.lower()
|
||||
assert alert.attack_type == "broadcast"
|
||||
assert alert.target_type == "broadcast"
|
||||
assert "all clients" in alert.description.lower()
|
||||
|
||||
def test_lookup_ap_no_callback(self):
|
||||
"""Test AP lookup when no callback is provided."""
|
||||
callback = MagicMock()
|
||||
detector = DeauthDetector(
|
||||
interface='wlan0mon',
|
||||
interface="wlan0mon",
|
||||
event_callback=callback,
|
||||
get_networks=None,
|
||||
)
|
||||
|
||||
result = detector._lookup_ap('AA:BB:CC:DD:EE:FF')
|
||||
result = detector._lookup_ap("AA:BB:CC:DD:EE:FF")
|
||||
|
||||
assert result['bssid'] == 'AA:BB:CC:DD:EE:FF'
|
||||
assert result['essid'] is None
|
||||
assert result['channel'] is None
|
||||
assert result["bssid"] == "AA:BB:CC:DD:EE:FF"
|
||||
assert result["essid"] is None
|
||||
assert result["channel"] is None
|
||||
|
||||
def test_lookup_ap_with_callback(self):
|
||||
"""Test AP lookup with callback."""
|
||||
callback = MagicMock()
|
||||
get_networks = MagicMock(return_value={
|
||||
'AA:BB:CC:DD:EE:FF': {'essid': 'TestNet', 'channel': 6}
|
||||
})
|
||||
get_networks = MagicMock(return_value={"AA:BB:CC:DD:EE:FF": {"essid": "TestNet", "channel": 6}})
|
||||
|
||||
detector = DeauthDetector(
|
||||
interface='wlan0mon',
|
||||
interface="wlan0mon",
|
||||
event_callback=callback,
|
||||
get_networks=get_networks,
|
||||
)
|
||||
|
||||
result = detector._lookup_ap('AA:BB:CC:DD:EE:FF')
|
||||
result = detector._lookup_ap("AA:BB:CC:DD:EE:FF")
|
||||
|
||||
assert result['bssid'] == 'AA:BB:CC:DD:EE:FF'
|
||||
assert result['essid'] == 'TestNet'
|
||||
assert result['channel'] == 6
|
||||
assert result["bssid"] == "AA:BB:CC:DD:EE:FF"
|
||||
assert result["essid"] == "TestNet"
|
||||
assert result["channel"] == 6
|
||||
|
||||
def test_check_spoofed_source(self):
|
||||
"""Test detection of spoofed AP source."""
|
||||
callback = MagicMock()
|
||||
get_networks = MagicMock(return_value={
|
||||
'AA:BB:CC:DD:EE:FF': {'essid': 'TestNet'}
|
||||
})
|
||||
get_networks = MagicMock(return_value={"AA:BB:CC:DD:EE:FF": {"essid": "TestNet"}})
|
||||
|
||||
detector = DeauthDetector(
|
||||
interface='wlan0mon',
|
||||
interface="wlan0mon",
|
||||
event_callback=callback,
|
||||
get_networks=get_networks,
|
||||
)
|
||||
|
||||
# Source matches known AP - spoofed
|
||||
assert detector._check_spoofed_source('AA:BB:CC:DD:EE:FF') is True
|
||||
assert detector._check_spoofed_source("AA:BB:CC:DD:EE:FF") is True
|
||||
|
||||
# Source does not match any AP - not spoofed
|
||||
assert detector._check_spoofed_source('11:22:33:44:55:66') is False
|
||||
assert detector._check_spoofed_source("11:22:33:44:55:66") is False
|
||||
|
||||
def test_cleanup_old_trackers(self):
|
||||
"""Test cleanup of old trackers."""
|
||||
callback = MagicMock()
|
||||
detector = DeauthDetector(
|
||||
interface='wlan0mon',
|
||||
interface="wlan0mon",
|
||||
event_callback=callback,
|
||||
)
|
||||
|
||||
@@ -431,34 +443,38 @@ class TestDeauthDetector:
|
||||
|
||||
# Add an old tracker
|
||||
old_tracker = DeauthTracker()
|
||||
old_tracker.add_packet(DeauthPacketInfo(
|
||||
timestamp=now - 100, # Very old
|
||||
frame_type='deauth',
|
||||
src_mac='AA:BB:CC:DD:EE:FF',
|
||||
dst_mac='11:22:33:44:55:66',
|
||||
bssid='99:88:77:66:55:44',
|
||||
reason_code=7,
|
||||
))
|
||||
detector._trackers[('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44')] = old_tracker
|
||||
old_tracker.add_packet(
|
||||
DeauthPacketInfo(
|
||||
timestamp=now - 100, # Very old
|
||||
frame_type="deauth",
|
||||
src_mac="AA:BB:CC:DD:EE:FF",
|
||||
dst_mac="11:22:33:44:55:66",
|
||||
bssid="99:88:77:66:55:44",
|
||||
reason_code=7,
|
||||
)
|
||||
)
|
||||
detector._trackers[("AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66", "99:88:77:66:55:44")] = old_tracker
|
||||
|
||||
# Add a recent tracker
|
||||
recent_tracker = DeauthTracker()
|
||||
recent_tracker.add_packet(DeauthPacketInfo(
|
||||
timestamp=now,
|
||||
frame_type='deauth',
|
||||
src_mac='BB:CC:DD:EE:FF:AA',
|
||||
dst_mac='22:33:44:55:66:77',
|
||||
bssid='88:77:66:55:44:33',
|
||||
reason_code=7,
|
||||
))
|
||||
detector._trackers[('BB:CC:DD:EE:FF:AA', '22:33:44:55:66:77', '88:77:66:55:44:33')] = recent_tracker
|
||||
recent_tracker.add_packet(
|
||||
DeauthPacketInfo(
|
||||
timestamp=now,
|
||||
frame_type="deauth",
|
||||
src_mac="BB:CC:DD:EE:FF:AA",
|
||||
dst_mac="22:33:44:55:66:77",
|
||||
bssid="88:77:66:55:44:33",
|
||||
reason_code=7,
|
||||
)
|
||||
)
|
||||
detector._trackers[("BB:CC:DD:EE:FF:AA", "22:33:44:55:66:77", "88:77:66:55:44:33")] = recent_tracker
|
||||
|
||||
detector._cleanup_old_trackers()
|
||||
|
||||
# Old tracker should be removed
|
||||
assert ('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44') not in detector._trackers
|
||||
assert ("AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66", "99:88:77:66:55:44") not in detector._trackers
|
||||
# Recent tracker should remain
|
||||
assert ('BB:CC:DD:EE:FF:AA', '22:33:44:55:66:77', '88:77:66:55:44:33') in detector._trackers
|
||||
assert ("BB:CC:DD:EE:FF:AA", "22:33:44:55:66:77", "88:77:66:55:44:33") in detector._trackers
|
||||
|
||||
|
||||
class TestReasonCodes:
|
||||
@@ -481,97 +497,53 @@ class TestReasonCodes:
|
||||
class TestDeauthDetectorIntegration:
|
||||
"""Integration tests for DeauthDetector with mocked scapy."""
|
||||
|
||||
@patch('utils.wifi.deauth_detector.time.time')
|
||||
@patch("utils.wifi.deauth_detector.time.time")
|
||||
def test_process_deauth_packet_generates_alert(self, mock_time):
|
||||
"""Test that processing packets generates alert when threshold exceeded."""
|
||||
mock_time.return_value = 1000.0
|
||||
|
||||
callback = MagicMock()
|
||||
detector = DeauthDetector(
|
||||
interface='wlan0mon',
|
||||
interface="wlan0mon",
|
||||
event_callback=callback,
|
||||
)
|
||||
|
||||
# Create a mock scapy packet
|
||||
mock_pkt = MagicMock()
|
||||
# Directly exercise tracker + alert logic (the same path _process_deauth_packet
|
||||
# follows after parsing the scapy packet) without calling the method itself,
|
||||
# avoiding any __globals__ patching that is read-only on Python 3.14.
|
||||
tracker_key = ("AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66", "99:88:77:66:55:44")
|
||||
|
||||
# Mock Dot11Deauth layer
|
||||
mock_deauth = MagicMock()
|
||||
mock_deauth.reason = 7
|
||||
for i in range(DEAUTH_ALERT_THRESHOLD + 5):
|
||||
mock_time.return_value = 1000.0 + i * 0.1
|
||||
|
||||
# Mock Dot11 layer
|
||||
mock_dot11 = MagicMock()
|
||||
mock_dot11.addr1 = '11:22:33:44:55:66' # dst
|
||||
mock_dot11.addr2 = 'AA:BB:CC:DD:EE:FF' # src
|
||||
mock_dot11.addr3 = '99:88:77:66:55:44' # bssid
|
||||
pkt_info = DeauthPacketInfo(
|
||||
timestamp=mock_time.return_value,
|
||||
frame_type="deauth",
|
||||
src_mac="AA:BB:CC:DD:EE:FF",
|
||||
dst_mac="11:22:33:44:55:66",
|
||||
bssid="99:88:77:66:55:44",
|
||||
reason_code=7,
|
||||
signal_dbm=-50,
|
||||
)
|
||||
|
||||
# Mock RadioTap layer
|
||||
mock_radiotap = MagicMock()
|
||||
mock_radiotap.dBm_AntSignal = -50
|
||||
detector._packets_captured += 1
|
||||
|
||||
# Set up haslayer behavior
|
||||
def haslayer_side_effect(layer):
|
||||
if 'Dot11Deauth' in str(layer):
|
||||
return True
|
||||
if 'Dot11Disas' in str(layer):
|
||||
return False
|
||||
return 'RadioTap' in str(layer)
|
||||
tracker = detector._trackers[tracker_key]
|
||||
tracker.add_packet(pkt_info)
|
||||
|
||||
mock_pkt.haslayer = haslayer_side_effect
|
||||
packets_in_window = tracker.get_packets_in_window(DEAUTH_DETECTION_WINDOW)
|
||||
packet_count = len(packets_in_window)
|
||||
|
||||
# Set up __getitem__ behavior
|
||||
def getitem_side_effect(layer):
|
||||
if 'Dot11Deauth' in str(layer):
|
||||
return mock_deauth
|
||||
if 'Dot11' in str(layer) and 'Deauth' not in str(layer):
|
||||
return mock_dot11
|
||||
if 'RadioTap' in str(layer):
|
||||
return mock_radiotap
|
||||
return MagicMock()
|
||||
|
||||
mock_pkt.__getitem__ = getitem_side_effect
|
||||
|
||||
# Patch the scapy imports inside _process_deauth_packet
|
||||
with patch('utils.wifi.deauth_detector.DeauthDetector._process_deauth_packet.__globals__', {
|
||||
'Dot11': MagicMock,
|
||||
'Dot11Deauth': MagicMock,
|
||||
'Dot11Disas': MagicMock,
|
||||
'RadioTap': MagicMock,
|
||||
}):
|
||||
# Process enough packets to trigger alert
|
||||
for i in range(DEAUTH_ALERT_THRESHOLD + 5):
|
||||
mock_time.return_value = 1000.0 + i * 0.1
|
||||
|
||||
# Manually simulate what _process_deauth_packet does
|
||||
pkt_info = DeauthPacketInfo(
|
||||
timestamp=mock_time.return_value,
|
||||
frame_type='deauth',
|
||||
src_mac='AA:BB:CC:DD:EE:FF',
|
||||
dst_mac='11:22:33:44:55:66',
|
||||
bssid='99:88:77:66:55:44',
|
||||
reason_code=7,
|
||||
signal_dbm=-50,
|
||||
if packet_count >= DEAUTH_ALERT_THRESHOLD and not tracker.alert_sent:
|
||||
alert = detector._generate_alert(
|
||||
tracker_key=tracker_key,
|
||||
packets=packets_in_window,
|
||||
packet_count=packet_count,
|
||||
)
|
||||
|
||||
detector._packets_captured += 1
|
||||
|
||||
tracker_key = ('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44')
|
||||
tracker = detector._trackers[tracker_key]
|
||||
tracker.add_packet(pkt_info)
|
||||
|
||||
packets_in_window = tracker.get_packets_in_window(DEAUTH_DETECTION_WINDOW)
|
||||
packet_count = len(packets_in_window)
|
||||
|
||||
if packet_count >= DEAUTH_ALERT_THRESHOLD and not tracker.alert_sent:
|
||||
alert = detector._generate_alert(
|
||||
tracker_key=tracker_key,
|
||||
packets=packets_in_window,
|
||||
packet_count=packet_count,
|
||||
)
|
||||
detector._alerts.append(alert)
|
||||
detector._alerts_generated += 1
|
||||
tracker.alert_sent = True
|
||||
detector.event_callback(alert.to_dict())
|
||||
detector._alerts.append(alert)
|
||||
detector._alerts_generated += 1
|
||||
tracker.alert_sent = True
|
||||
detector.event_callback(alert.to_dict())
|
||||
|
||||
# Verify alert was generated
|
||||
assert detector._alerts_generated == 1
|
||||
@@ -580,6 +552,6 @@ class TestDeauthDetectorIntegration:
|
||||
|
||||
# Verify callback was called with alert data
|
||||
call_args = callback.call_args[0][0]
|
||||
assert call_args['type'] == 'deauth_alert'
|
||||
assert call_args['attacker']['mac'] == 'AA:BB:CC:DD:EE:FF'
|
||||
assert call_args['target']['mac'] == '11:22:33:44:55:66'
|
||||
assert call_args["type"] == "deauth_alert"
|
||||
assert call_args["attacker"]["mac"] == "AA:BB:CC:DD:EE:FF"
|
||||
assert call_args["target"]["mac"] == "11:22:33:44:55:66"
|
||||
|
||||
@@ -126,7 +126,7 @@ class TestMeshcoreClientStateMachine:
|
||||
client.get_queue().get_nowait()
|
||||
# Call on_connected directly (simulating what AsyncWorker would call)
|
||||
client.on_connected(transport="serial", device="/dev/ttyUSB0")
|
||||
assert client.get_state() == ConnectionState.CONNECTED
|
||||
assert client.get_state()[0] == ConnectionState.CONNECTED
|
||||
event = client.get_queue().get_nowait()
|
||||
assert event["type"] == "status"
|
||||
assert event["data"]["state"] == "connected"
|
||||
|
||||
@@ -164,7 +164,7 @@ class TestConnectionStateTransitions:
|
||||
client = MeshcoreClient()
|
||||
client.on_connected(transport="serial", device="/dev/ttyUSB0")
|
||||
|
||||
assert client.get_state() == ConnectionState.CONNECTED
|
||||
assert client.get_state()[0] == ConnectionState.CONNECTED
|
||||
event = client.get_queue().get_nowait()
|
||||
assert event["type"] == "status"
|
||||
assert event["data"]["state"] == "connected"
|
||||
@@ -175,7 +175,7 @@ class TestConnectionStateTransitions:
|
||||
client = MeshcoreClient()
|
||||
client.on_error("timeout")
|
||||
|
||||
assert client.get_state() == ConnectionState.ERROR
|
||||
assert client.get_state()[0] == ConnectionState.ERROR
|
||||
event = client.get_queue().get_nowait()
|
||||
assert event["data"]["state"] == "error"
|
||||
assert event["data"].get("message") == "timeout"
|
||||
|
||||
@@ -25,7 +25,7 @@ def client(app):
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_meshcore_client():
|
||||
mc = MagicMock()
|
||||
mc.get_state.return_value = MagicMock(value="disconnected")
|
||||
mc.get_state.return_value = (MagicMock(value="disconnected"), None)
|
||||
mc.get_messages.return_value = []
|
||||
mc.get_nodes.return_value = []
|
||||
mc.get_repeaters.return_value = []
|
||||
|
||||
+109
-111
@@ -18,12 +18,14 @@ import pytest
|
||||
# Utility Module Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestMeshtasticAvailability:
|
||||
"""Tests for SDK availability checks."""
|
||||
|
||||
def test_is_meshtastic_available_returns_bool(self):
|
||||
"""is_meshtastic_available should return a boolean."""
|
||||
from utils.meshtastic import is_meshtastic_available
|
||||
|
||||
result = is_meshtastic_available()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
@@ -36,10 +38,10 @@ class TestMeshtasticMessage:
|
||||
from utils.meshtastic import MeshtasticMessage
|
||||
|
||||
msg = MeshtasticMessage(
|
||||
from_id='!a1b2c3d4',
|
||||
to_id='^all',
|
||||
message='Hello mesh!',
|
||||
portnum='TEXT_MESSAGE_APP',
|
||||
from_id="!a1b2c3d4",
|
||||
to_id="^all",
|
||||
message="Hello mesh!",
|
||||
portnum="TEXT_MESSAGE_APP",
|
||||
channel=0,
|
||||
rssi=-95,
|
||||
snr=-3.5,
|
||||
@@ -49,26 +51,28 @@ class TestMeshtasticMessage:
|
||||
|
||||
d = msg.to_dict()
|
||||
|
||||
assert d['type'] == 'meshtastic'
|
||||
assert d['from'] == '!a1b2c3d4'
|
||||
assert d['to'] == '^all'
|
||||
assert d['message'] == 'Hello mesh!'
|
||||
assert d['portnum'] == 'TEXT_MESSAGE_APP'
|
||||
assert d['channel'] == 0
|
||||
assert d['rssi'] == -95
|
||||
assert d['snr'] == -3.5
|
||||
assert d['hop_limit'] == 3
|
||||
assert '2026-01-27' in d['timestamp']
|
||||
assert d["type"] == "meshtastic"
|
||||
assert d["from"] == "!a1b2c3d4"
|
||||
assert d["to"] == "^all"
|
||||
assert d["message"] == "Hello mesh!"
|
||||
assert d["portnum"] == "TEXT_MESSAGE_APP"
|
||||
assert d["channel"] == 0
|
||||
assert d["rssi"] == -95
|
||||
assert d["snr"] == -3.5
|
||||
assert d["hop_limit"] == 3
|
||||
assert isinstance(d["timestamp"], float)
|
||||
# 2026-01-27 12:00:00 UTC as Unix epoch
|
||||
assert d["timestamp"] == pytest.approx(1769515200.0)
|
||||
|
||||
def test_message_with_none_values(self):
|
||||
"""MeshtasticMessage should handle None values."""
|
||||
from utils.meshtastic import MeshtasticMessage
|
||||
|
||||
msg = MeshtasticMessage(
|
||||
from_id='!00000001',
|
||||
to_id='!00000002',
|
||||
from_id="!00000001",
|
||||
to_id="!00000002",
|
||||
message=None,
|
||||
portnum='POSITION_APP',
|
||||
portnum="POSITION_APP",
|
||||
channel=1,
|
||||
rssi=None,
|
||||
snr=None,
|
||||
@@ -78,9 +82,9 @@ class TestMeshtasticMessage:
|
||||
|
||||
d = msg.to_dict()
|
||||
|
||||
assert d['message'] is None
|
||||
assert d['rssi'] is None
|
||||
assert d['snr'] is None
|
||||
assert d["message"] is None
|
||||
assert d["rssi"] is None
|
||||
assert d["snr"] is None
|
||||
|
||||
|
||||
class TestChannelConfig:
|
||||
@@ -92,50 +96,50 @@ class TestChannelConfig:
|
||||
|
||||
config = ChannelConfig(
|
||||
index=0,
|
||||
name='Primary',
|
||||
psk=b'\x01\x02\x03\x04' * 8, # 32-byte key
|
||||
name="Primary",
|
||||
psk=b"\x01\x02\x03\x04" * 8, # 32-byte key
|
||||
role=1, # PRIMARY
|
||||
)
|
||||
|
||||
d = config.to_dict()
|
||||
|
||||
assert 'psk' not in d # Raw PSK should not be in dict
|
||||
assert d['index'] == 0
|
||||
assert d['name'] == 'Primary'
|
||||
assert d['role'] == 'PRIMARY'
|
||||
assert d['encrypted'] is True
|
||||
assert d['key_type'] == 'AES-256'
|
||||
assert "psk" not in d # Raw PSK should not be in dict
|
||||
assert d["index"] == 0
|
||||
assert d["name"] == "Primary"
|
||||
assert d["role"] == "PRIMARY"
|
||||
assert d["encrypted"] is True
|
||||
assert d["key_type"] == "AES-256"
|
||||
|
||||
def test_channel_default_key_detection(self):
|
||||
"""ChannelConfig should detect default key."""
|
||||
from utils.meshtastic import ChannelConfig
|
||||
|
||||
# Default key is single byte 0x01
|
||||
config = ChannelConfig(index=0, name='Test', psk=b'\x01', role=1)
|
||||
config = ChannelConfig(index=0, name="Test", psk=b"\x01", role=1)
|
||||
d = config.to_dict()
|
||||
|
||||
assert d['is_default_key'] is True
|
||||
assert d['key_type'] == 'default'
|
||||
assert d["is_default_key"] is True
|
||||
assert d["key_type"] == "default"
|
||||
|
||||
def test_channel_aes128_detection(self):
|
||||
"""ChannelConfig should detect AES-128 key."""
|
||||
from utils.meshtastic import ChannelConfig
|
||||
|
||||
config = ChannelConfig(index=0, name='Test', psk=b'0' * 16, role=1)
|
||||
config = ChannelConfig(index=0, name="Test", psk=b"0" * 16, role=1)
|
||||
d = config.to_dict()
|
||||
|
||||
assert d['key_type'] == 'AES-128'
|
||||
assert d['encrypted'] is True
|
||||
assert d["key_type"] == "AES-128"
|
||||
assert d["encrypted"] is True
|
||||
|
||||
def test_channel_no_encryption(self):
|
||||
"""ChannelConfig should detect no encryption."""
|
||||
from utils.meshtastic import ChannelConfig
|
||||
|
||||
config = ChannelConfig(index=0, name='Test', psk=b'', role=1)
|
||||
config = ChannelConfig(index=0, name="Test", psk=b"", role=1)
|
||||
d = config.to_dict()
|
||||
|
||||
assert d['key_type'] == 'none'
|
||||
assert d['encrypted'] is False
|
||||
assert d["key_type"] == "none"
|
||||
assert d["encrypted"] is False
|
||||
|
||||
|
||||
class TestPSKParsing:
|
||||
@@ -146,29 +150,29 @@ class TestPSKParsing:
|
||||
from utils.meshtastic import MeshtasticClient
|
||||
|
||||
client = MeshtasticClient()
|
||||
result = client._parse_psk('none')
|
||||
result = client._parse_psk("none")
|
||||
|
||||
assert result == b''
|
||||
assert result == b""
|
||||
|
||||
def test_parse_psk_default(self):
|
||||
"""Should parse 'default' as single byte."""
|
||||
from utils.meshtastic import MeshtasticClient
|
||||
|
||||
client = MeshtasticClient()
|
||||
result = client._parse_psk('default')
|
||||
result = client._parse_psk("default")
|
||||
|
||||
assert result == b'\x01'
|
||||
assert result == b"\x01"
|
||||
|
||||
def test_parse_psk_random(self):
|
||||
"""Should generate 32 random bytes for 'random'."""
|
||||
from utils.meshtastic import MeshtasticClient
|
||||
|
||||
client = MeshtasticClient()
|
||||
result = client._parse_psk('random')
|
||||
result = client._parse_psk("random")
|
||||
|
||||
assert len(result) == 32
|
||||
# Verify it's actually random (two calls should differ)
|
||||
result2 = client._parse_psk('random')
|
||||
result2 = client._parse_psk("random")
|
||||
assert result != result2
|
||||
|
||||
def test_parse_psk_base64(self):
|
||||
@@ -179,8 +183,8 @@ class TestPSKParsing:
|
||||
|
||||
client = MeshtasticClient()
|
||||
# 32-byte key encoded as base64
|
||||
key = b'A' * 32
|
||||
encoded = 'base64:' + base64.b64encode(key).decode()
|
||||
key = b"A" * 32
|
||||
encoded = "base64:" + base64.b64encode(key).decode()
|
||||
|
||||
result = client._parse_psk(encoded)
|
||||
|
||||
@@ -192,9 +196,9 @@ class TestPSKParsing:
|
||||
|
||||
client = MeshtasticClient()
|
||||
# 16-byte key as hex
|
||||
result = client._parse_psk('0x' + '41' * 16)
|
||||
result = client._parse_psk("0x" + "41" * 16)
|
||||
|
||||
assert result == b'A' * 16
|
||||
assert result == b"A" * 16
|
||||
|
||||
def test_parse_psk_simple_passphrase(self):
|
||||
"""Should hash simple passphrase to 32-byte key."""
|
||||
@@ -203,9 +207,9 @@ class TestPSKParsing:
|
||||
from utils.meshtastic import MeshtasticClient
|
||||
|
||||
client = MeshtasticClient()
|
||||
result = client._parse_psk('simple:MySecretPassword')
|
||||
result = client._parse_psk("simple:MySecretPassword")
|
||||
|
||||
expected = hashlib.sha256(b'MySecretPassword').digest()
|
||||
expected = hashlib.sha256(b"MySecretPassword").digest()
|
||||
assert result == expected
|
||||
assert len(result) == 32
|
||||
|
||||
@@ -215,8 +219,8 @@ class TestPSKParsing:
|
||||
|
||||
client = MeshtasticClient()
|
||||
|
||||
assert client._parse_psk('base64:!!!invalid!!!') is None
|
||||
assert client._parse_psk('0xZZZZ') is None
|
||||
assert client._parse_psk("base64:!!!invalid!!!") is None
|
||||
assert client._parse_psk("0xZZZZ") is None
|
||||
|
||||
def test_parse_psk_raw_base64(self):
|
||||
"""Should accept raw base64 without prefix."""
|
||||
@@ -225,7 +229,7 @@ class TestPSKParsing:
|
||||
from utils.meshtastic import MeshtasticClient
|
||||
|
||||
client = MeshtasticClient()
|
||||
key = b'B' * 16
|
||||
key = b"B" * 16
|
||||
encoded = base64.b64encode(key).decode()
|
||||
|
||||
result = client._parse_psk(encoded)
|
||||
@@ -242,7 +246,7 @@ class TestNodeIdFormatting:
|
||||
|
||||
result = MeshtasticClient._format_node_id(0xDEADBEEF)
|
||||
|
||||
assert result == '!deadbeef'
|
||||
assert result == "!deadbeef"
|
||||
|
||||
def test_format_broadcast(self):
|
||||
"""Should format broadcast address."""
|
||||
@@ -250,13 +254,14 @@ class TestNodeIdFormatting:
|
||||
|
||||
result = MeshtasticClient._format_node_id(0xFFFFFFFF)
|
||||
|
||||
assert result == '^all'
|
||||
assert result == "^all"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Route Tests (Mocked)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestMeshtasticRoutes:
|
||||
"""Tests for Flask route endpoints."""
|
||||
|
||||
@@ -268,7 +273,7 @@ class TestMeshtasticRoutes:
|
||||
from routes.meshtastic import meshtastic_bp
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['TESTING'] = True
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(meshtastic_bp)
|
||||
|
||||
return app
|
||||
@@ -280,144 +285,137 @@ class TestMeshtasticRoutes:
|
||||
|
||||
def test_status_sdk_not_installed(self, client):
|
||||
"""GET /meshtastic/status should report SDK unavailable."""
|
||||
with patch('routes.meshtastic.is_meshtastic_available', return_value=False):
|
||||
response = client.get('/meshtastic/status')
|
||||
with patch("routes.meshtastic.is_meshtastic_available", return_value=False):
|
||||
response = client.get("/meshtastic/status")
|
||||
data = json.loads(response.data)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert data['available'] is False
|
||||
assert 'not installed' in data['error']
|
||||
assert data["available"] is False
|
||||
assert "not installed" in data["error"]
|
||||
|
||||
def test_status_not_connected(self, client):
|
||||
"""GET /meshtastic/status should report not running when disconnected."""
|
||||
with patch('routes.meshtastic.is_meshtastic_available', return_value=True):
|
||||
with patch('routes.meshtastic.get_meshtastic_client', return_value=None):
|
||||
response = client.get('/meshtastic/status')
|
||||
with patch("routes.meshtastic.is_meshtastic_available", return_value=True):
|
||||
with patch("routes.meshtastic.get_meshtastic_client", return_value=None):
|
||||
response = client.get("/meshtastic/status")
|
||||
data = json.loads(response.data)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert data['available'] is True
|
||||
assert data['running'] is False
|
||||
assert data["available"] is True
|
||||
assert data["running"] is False
|
||||
|
||||
def test_start_sdk_not_installed(self, client):
|
||||
"""POST /meshtastic/start should fail if SDK not installed."""
|
||||
with patch('routes.meshtastic.is_meshtastic_available', return_value=False):
|
||||
response = client.post('/meshtastic/start')
|
||||
with patch("routes.meshtastic.is_meshtastic_available", return_value=False):
|
||||
response = client.post("/meshtastic/start")
|
||||
data = json.loads(response.data)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert data['status'] == 'error'
|
||||
assert data["status"] == "error"
|
||||
|
||||
def test_stop_always_succeeds(self, client):
|
||||
"""POST /meshtastic/stop should always succeed."""
|
||||
with patch('routes.meshtastic.stop_meshtastic'):
|
||||
response = client.post('/meshtastic/stop')
|
||||
with patch("routes.meshtastic.stop_meshtastic"):
|
||||
response = client.post("/meshtastic/stop")
|
||||
data = json.loads(response.data)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert data['status'] == 'stopped'
|
||||
assert data["status"] == "stopped"
|
||||
|
||||
def test_channels_not_connected(self, client):
|
||||
"""GET /meshtastic/channels should fail if not connected."""
|
||||
with patch('routes.meshtastic.get_meshtastic_client', return_value=None):
|
||||
response = client.get('/meshtastic/channels')
|
||||
with patch("routes.meshtastic.get_meshtastic_client", return_value=None):
|
||||
response = client.get("/meshtastic/channels")
|
||||
data = json.loads(response.data)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'Not connected' in data['message']
|
||||
assert "Not connected" in data["message"]
|
||||
|
||||
def test_configure_channel_invalid_index(self, client):
|
||||
"""POST /meshtastic/channels/<id> should reject invalid index."""
|
||||
mock_client = Mock()
|
||||
mock_client.is_running = True
|
||||
|
||||
with patch('routes.meshtastic.get_meshtastic_client', return_value=mock_client):
|
||||
response = client.post(
|
||||
'/meshtastic/channels/10',
|
||||
json={'name': 'Test'},
|
||||
content_type='application/json'
|
||||
)
|
||||
with patch("routes.meshtastic.get_meshtastic_client", return_value=mock_client):
|
||||
response = client.post("/meshtastic/channels/10", json={"name": "Test"}, content_type="application/json")
|
||||
data = json.loads(response.data)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'must be 0-7' in data['message']
|
||||
assert "must be 0-7" in data["message"]
|
||||
|
||||
def test_configure_channel_no_params(self, client):
|
||||
"""POST /meshtastic/channels/<id> should require name or psk."""
|
||||
mock_client = Mock()
|
||||
mock_client.is_running = True
|
||||
|
||||
with patch('routes.meshtastic.get_meshtastic_client', return_value=mock_client):
|
||||
response = client.post(
|
||||
'/meshtastic/channels/0',
|
||||
json={},
|
||||
content_type='application/json'
|
||||
)
|
||||
with patch("routes.meshtastic.get_meshtastic_client", return_value=mock_client):
|
||||
response = client.post("/meshtastic/channels/0", json={}, content_type="application/json")
|
||||
data = json.loads(response.data)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'Must provide' in data['message']
|
||||
assert "Must provide" in data["message"]
|
||||
|
||||
def test_messages_empty(self, client):
|
||||
"""GET /meshtastic/messages should return empty list initially."""
|
||||
with patch('routes.meshtastic._recent_messages', []):
|
||||
response = client.get('/meshtastic/messages')
|
||||
with patch("routes.meshtastic._recent_messages", []):
|
||||
response = client.get("/meshtastic/messages")
|
||||
data = json.loads(response.data)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert data['status'] == 'ok'
|
||||
assert data['messages'] == []
|
||||
assert data['count'] == 0
|
||||
assert data["status"] == "ok"
|
||||
assert data["messages"] == []
|
||||
assert data["count"] == 0
|
||||
|
||||
def test_messages_with_limit(self, client):
|
||||
"""GET /meshtastic/messages should respect limit param."""
|
||||
test_messages = [{'id': i} for i in range(10)]
|
||||
test_messages = [{"id": i} for i in range(10)]
|
||||
|
||||
with patch('routes.meshtastic._recent_messages', test_messages):
|
||||
response = client.get('/meshtastic/messages?limit=3')
|
||||
with patch("routes.meshtastic._recent_messages", test_messages):
|
||||
response = client.get("/meshtastic/messages?limit=3")
|
||||
data = json.loads(response.data)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(data['messages']) == 3
|
||||
assert len(data["messages"]) == 3
|
||||
# Should return last 3 (most recent)
|
||||
assert data['messages'][0]['id'] == 7
|
||||
assert data["messages"][0]["id"] == 7
|
||||
|
||||
def test_messages_filter_by_channel(self, client):
|
||||
"""GET /meshtastic/messages should filter by channel."""
|
||||
test_messages = [
|
||||
{'id': 1, 'channel': 0},
|
||||
{'id': 2, 'channel': 1},
|
||||
{'id': 3, 'channel': 0},
|
||||
{"id": 1, "channel": 0},
|
||||
{"id": 2, "channel": 1},
|
||||
{"id": 3, "channel": 0},
|
||||
]
|
||||
|
||||
with patch('routes.meshtastic._recent_messages', test_messages):
|
||||
response = client.get('/meshtastic/messages?channel=0')
|
||||
with patch("routes.meshtastic._recent_messages", test_messages):
|
||||
response = client.get("/meshtastic/messages?channel=0")
|
||||
data = json.loads(response.data)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(data['messages']) == 2
|
||||
assert all(m['channel'] == 0 for m in data['messages'])
|
||||
assert len(data["messages"]) == 2
|
||||
assert all(m["channel"] == 0 for m in data["messages"])
|
||||
|
||||
def test_stream_endpoint_exists(self, client):
|
||||
"""GET /meshtastic/stream should return SSE content type."""
|
||||
response = client.get('/meshtastic/stream')
|
||||
response = client.get("/meshtastic/stream")
|
||||
|
||||
assert response.content_type == 'text/event-stream'
|
||||
assert response.content_type.startswith("text/event-stream")
|
||||
|
||||
def test_node_not_connected(self, client):
|
||||
"""GET /meshtastic/node should fail if not connected."""
|
||||
with patch('routes.meshtastic.get_meshtastic_client', return_value=None):
|
||||
response = client.get('/meshtastic/node')
|
||||
with patch("routes.meshtastic.get_meshtastic_client", return_value=None):
|
||||
response = client.get("/meshtastic/node")
|
||||
data = json.loads(response.data)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'Not connected' in data['message']
|
||||
assert "Not connected" in data["message"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Integration Tests (Mocked SDK)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestMeshtasticClientMocked:
|
||||
"""Tests for MeshtasticClient with mocked SDK."""
|
||||
|
||||
@@ -435,12 +433,12 @@ class TestMeshtasticClientMocked:
|
||||
"""MeshtasticClient.connect should fail gracefully without SDK."""
|
||||
from utils.meshtastic import MeshtasticClient
|
||||
|
||||
with patch('utils.meshtastic.HAS_MESHTASTIC', False):
|
||||
with patch("utils.meshtastic.HAS_MESHTASTIC", False):
|
||||
client = MeshtasticClient()
|
||||
result = client.connect()
|
||||
|
||||
assert result is False
|
||||
assert 'not installed' in client.error
|
||||
assert "not installed" in client.error
|
||||
|
||||
def test_client_disconnect_idempotent(self):
|
||||
"""MeshtasticClient.disconnect should be safe to call multiple times."""
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Consistency checks between the mode registry and the template/assets."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
REGISTRY = ROOT / "static" / "js" / "mode-registry.js"
|
||||
INDEX = ROOT / "templates" / "index.html"
|
||||
|
||||
|
||||
def _registry_modes() -> set[str]:
|
||||
src = REGISTRY.read_text()
|
||||
return set(re.findall(r"^\s{4}([a-z_]+):\s*\{", src, re.M))
|
||||
|
||||
|
||||
def test_registry_has_all_modes():
|
||||
"""The registry must declare a sane number of modes (28 at creation)."""
|
||||
modes = _registry_modes()
|
||||
assert len(modes) >= 28, f"registry lost modes: {sorted(modes)}"
|
||||
|
||||
|
||||
def test_registry_modes_have_partials():
|
||||
"""Every partial included by index.html must exist on disk."""
|
||||
html = INDEX.read_text()
|
||||
partials = set(re.findall(r"partials/modes/([\w.-]+)\.html", html))
|
||||
for partial in partials:
|
||||
assert (ROOT / "templates" / "partials" / "modes" / f"{partial}.html").exists(), (
|
||||
f"index.html includes missing partial: {partial}"
|
||||
)
|
||||
|
||||
|
||||
def test_no_orphan_mode_assets():
|
||||
"""Every modes/*.js and modes/*.css file is referenced somewhere."""
|
||||
referenced = INDEX.read_text() + REGISTRY.read_text()
|
||||
# ground_station_waterfall.js belongs to the satellite dashboard
|
||||
referenced += (ROOT / "templates" / "satellite_dashboard.html").read_text()
|
||||
for asset_dir, ext in [("static/js/modes", ".js"), ("static/css/modes", ".css")]:
|
||||
for f in (ROOT / asset_dir).glob(f"*{ext}"):
|
||||
assert f.name in referenced, f"orphaned mode asset: {f}"
|
||||
+119
-149
@@ -6,21 +6,21 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
@pytest.fixture(scope="session")
|
||||
def app():
|
||||
"""Create application for testing."""
|
||||
import app as app_module
|
||||
from routes import register_blueprints
|
||||
from utils.database import init_db
|
||||
|
||||
app_module.app.config['TESTING'] = True
|
||||
app_module.app.config["TESTING"] = True
|
||||
|
||||
# Initialize database for settings tests
|
||||
init_db()
|
||||
|
||||
# Register blueprints only if not already registered (normally done in main())
|
||||
# Check if any blueprint is already registered to avoid re-registration
|
||||
if 'pager' not in app_module.app.blueprints:
|
||||
if "pager" not in app_module.app.blueprints:
|
||||
register_blueprints(app_module.app)
|
||||
|
||||
return app_module.app
|
||||
@@ -29,7 +29,10 @@ def app():
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create test client."""
|
||||
return app.test_client()
|
||||
c = app.test_client()
|
||||
with c.session_transaction() as sess:
|
||||
sess["logged_in"] = True
|
||||
return c
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
@@ -37,55 +40,52 @@ class TestHealthEndpoint:
|
||||
|
||||
def test_health_check(self, client):
|
||||
"""Test health endpoint returns expected data."""
|
||||
response = client.get('/health')
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'healthy'
|
||||
assert 'version' in data
|
||||
assert 'uptime_seconds' in data
|
||||
assert 'processes' in data
|
||||
assert 'data' in data
|
||||
assert data["status"] == "healthy"
|
||||
assert "version" in data
|
||||
assert "uptime_seconds" in data
|
||||
assert "processes" in data
|
||||
assert "data" in data
|
||||
|
||||
def test_health_process_status(self, client):
|
||||
"""Test health endpoint reports process status."""
|
||||
response = client.get('/health')
|
||||
response = client.get("/health")
|
||||
data = json.loads(response.data)
|
||||
|
||||
processes = data['processes']
|
||||
assert 'pager' in processes
|
||||
assert 'sensor' in processes
|
||||
assert 'adsb' in processes
|
||||
assert 'wifi' in processes
|
||||
assert 'bluetooth' in processes
|
||||
processes = data["processes"]
|
||||
assert "pager" in processes
|
||||
assert "sensor" in processes
|
||||
assert "adsb" in processes
|
||||
assert "wifi" in processes
|
||||
assert "bluetooth" in processes
|
||||
|
||||
|
||||
class TestDevicesEndpoint:
|
||||
"""Tests for devices endpoint."""
|
||||
|
||||
def test_get_devices(self, client):
|
||||
"""Test getting device list."""
|
||||
response = client.get('/devices')
|
||||
response = client.get("/devices")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert isinstance(data, list)
|
||||
|
||||
@patch('app.SDRFactory.detect_devices')
|
||||
@patch("app.SDRFactory.detect_devices")
|
||||
def test_devices_returns_list(self, mock_detect, client):
|
||||
"""Test devices endpoint returns list format."""
|
||||
mock_device = MagicMock()
|
||||
mock_device.to_dict.return_value = {
|
||||
'index': 0,
|
||||
'name': 'Test RTL-SDR',
|
||||
'sdr_type': 'rtlsdr'
|
||||
}
|
||||
mock_device.to_dict.return_value = {"index": 0, "name": "Test RTL-SDR", "sdr_type": "rtlsdr"}
|
||||
mock_detect.return_value = [mock_device]
|
||||
|
||||
response = client.get('/devices')
|
||||
response = client.get("/devices")
|
||||
data = json.loads(response.data)
|
||||
|
||||
assert len(data) == 1
|
||||
assert data[0]['name'] == 'Test RTL-SDR'
|
||||
assert data[0]["name"] == "Test RTL-SDR"
|
||||
|
||||
|
||||
class TestDependenciesEndpoint:
|
||||
@@ -93,14 +93,14 @@ class TestDependenciesEndpoint:
|
||||
|
||||
def test_get_dependencies(self, client):
|
||||
"""Test getting dependency status."""
|
||||
response = client.get('/dependencies')
|
||||
response = client.get("/dependencies")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'os' in data
|
||||
assert 'pkg_manager' in data
|
||||
assert 'modes' in data
|
||||
assert data["status"] == "success"
|
||||
assert "os" in data
|
||||
assert "pkg_manager" in data
|
||||
assert "modes" in data
|
||||
|
||||
|
||||
class TestSettingsEndpoints:
|
||||
@@ -108,86 +108,70 @@ class TestSettingsEndpoints:
|
||||
|
||||
def test_get_settings(self, client):
|
||||
"""Test getting all settings."""
|
||||
response = client.get('/settings')
|
||||
response = client.get("/settings")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'settings' in data
|
||||
assert data["status"] == "success"
|
||||
assert "settings" in data
|
||||
|
||||
def test_save_settings(self, client):
|
||||
"""Test saving settings."""
|
||||
response = client.post(
|
||||
'/settings',
|
||||
data=json.dumps({'test_key': 'test_value'}),
|
||||
content_type='application/json'
|
||||
"/settings", data=json.dumps({"test_key": "test_value"}), content_type="application/json"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'test_key' in data['saved']
|
||||
assert data["status"] == "success"
|
||||
assert "test_key" in data["saved"]
|
||||
|
||||
def test_save_empty_settings(self, client):
|
||||
"""Test saving empty settings returns error."""
|
||||
response = client.post(
|
||||
'/settings',
|
||||
data=json.dumps({}),
|
||||
content_type='application/json'
|
||||
)
|
||||
response = client.post("/settings", data=json.dumps({}), content_type="application/json")
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_get_single_setting(self, client):
|
||||
"""Test getting a single setting."""
|
||||
# First save a setting
|
||||
client.post(
|
||||
'/settings',
|
||||
data=json.dumps({'my_setting': 'my_value'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
client.post("/settings", data=json.dumps({"my_setting": "my_value"}), content_type="application/json")
|
||||
|
||||
# Then retrieve it
|
||||
response = client.get('/settings/my_setting')
|
||||
response = client.get("/settings/my_setting")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['value'] == 'my_value'
|
||||
assert data["status"] == "success"
|
||||
assert data["value"] == "my_value"
|
||||
|
||||
def test_get_nonexistent_setting(self, client):
|
||||
"""Test getting a setting that doesn't exist."""
|
||||
response = client.get('/settings/nonexistent_key_xyz')
|
||||
response = client.get("/settings/nonexistent_key_xyz")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_update_setting(self, client):
|
||||
"""Test updating a setting via PUT."""
|
||||
response = client.put(
|
||||
'/settings/update_test',
|
||||
data=json.dumps({'value': 'updated_value'}),
|
||||
content_type='application/json'
|
||||
"/settings/update_test", data=json.dumps({"value": "updated_value"}), content_type="application/json"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['value'] == 'updated_value'
|
||||
assert data["status"] == "success"
|
||||
assert data["value"] == "updated_value"
|
||||
|
||||
def test_delete_setting(self, client):
|
||||
"""Test deleting a setting."""
|
||||
# First create a setting
|
||||
client.post(
|
||||
'/settings',
|
||||
data=json.dumps({'delete_me': 'value'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
client.post("/settings", data=json.dumps({"delete_me": "value"}), content_type="application/json")
|
||||
|
||||
# Then delete it
|
||||
response = client.delete('/settings/delete_me')
|
||||
response = client.delete("/settings/delete_me")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['deleted'] is True
|
||||
assert data["status"] == "success"
|
||||
assert data["deleted"] is True
|
||||
|
||||
def test_save_observer_location_updates_env_and_runtime_defaults(self, client, monkeypatch, tmp_path):
|
||||
"""Saving observer location should persist to .env and update in-memory defaults."""
|
||||
@@ -198,26 +182,24 @@ class TestSettingsEndpoints:
|
||||
from routes import settings as settings_routes
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
sess["logged_in"] = True
|
||||
|
||||
env_path = tmp_path / '.env'
|
||||
monkeypatch.setattr(settings_routes, '_get_env_file_path', lambda: env_path)
|
||||
env_path = tmp_path / ".env"
|
||||
monkeypatch.setattr(settings_routes, "_get_env_file_path", lambda: env_path)
|
||||
|
||||
response = client.post(
|
||||
'/settings/observer-location',
|
||||
data=json.dumps({'lat': 48.0, 'lon': 16.16}),
|
||||
content_type='application/json'
|
||||
"/settings/observer-location", data=json.dumps({"lat": 48.0, "lon": 16.16}), content_type="application/json"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['lat'] == 48.0
|
||||
assert data['lon'] == 16.16
|
||||
assert data["status"] == "success"
|
||||
assert data["lat"] == 48.0
|
||||
assert data["lon"] == 16.16
|
||||
|
||||
env_text = env_path.read_text()
|
||||
assert 'INTERCEPT_DEFAULT_LAT=48.0' in env_text
|
||||
assert 'INTERCEPT_DEFAULT_LON=16.16' in env_text
|
||||
assert "INTERCEPT_DEFAULT_LAT=48.0" in env_text
|
||||
assert "INTERCEPT_DEFAULT_LON=16.16" in env_text
|
||||
|
||||
assert config.DEFAULT_LATITUDE == 48.0
|
||||
assert config.DEFAULT_LONGITUDE == 16.16
|
||||
@@ -231,12 +213,10 @@ class TestSettingsEndpoints:
|
||||
def test_save_observer_location_rejects_invalid_values(self, client):
|
||||
"""Observer location save should validate coordinates."""
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
sess["logged_in"] = True
|
||||
|
||||
response = client.post(
|
||||
'/settings/observer-location',
|
||||
data=json.dumps({'lat': 200, 'lon': 16.16}),
|
||||
content_type='application/json'
|
||||
"/settings/observer-location", data=json.dumps({"lat": 200, "lon": 16.16}), content_type="application/json"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
@@ -246,22 +226,22 @@ class TestCorrelationEndpoints:
|
||||
|
||||
def test_get_correlations(self, client):
|
||||
"""Test getting device correlations."""
|
||||
response = client.get('/correlation')
|
||||
response = client.get("/correlation")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'correlations' in data
|
||||
assert 'wifi_count' in data
|
||||
assert 'bt_count' in data
|
||||
assert data["status"] == "success"
|
||||
assert "correlations" in data
|
||||
assert "wifi_count" in data
|
||||
assert "bt_count" in data
|
||||
|
||||
def test_correlations_with_confidence_filter(self, client):
|
||||
"""Test correlation endpoint respects confidence filter."""
|
||||
response = client.get('/correlation?min_confidence=0.8')
|
||||
response = client.get("/correlation?min_confidence=0.8")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data["status"] == "success"
|
||||
|
||||
|
||||
class TestListeningPostEndpoints:
|
||||
@@ -269,63 +249,63 @@ class TestListeningPostEndpoints:
|
||||
|
||||
def test_tools_check(self, client):
|
||||
"""Test listening post tools availability check."""
|
||||
response = client.get('/listening/tools')
|
||||
response = client.get("/receiver/tools")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert 'rtl_fm' in data
|
||||
assert 'available' in data
|
||||
assert "rtl_fm" in data
|
||||
assert "available" in data
|
||||
|
||||
def test_scanner_status(self, client):
|
||||
"""Test scanner status endpoint."""
|
||||
response = client.get('/listening/scanner/status')
|
||||
response = client.get("/receiver/scanner/status")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert 'running' in data
|
||||
assert 'paused' in data
|
||||
assert 'current_freq' in data
|
||||
assert "running" in data
|
||||
assert "paused" in data
|
||||
assert "current_freq" in data
|
||||
|
||||
def test_presets(self, client):
|
||||
"""Test scanner presets endpoint."""
|
||||
response = client.get('/listening/presets')
|
||||
response = client.get("/receiver/presets")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert 'presets' in data
|
||||
assert len(data['presets']) > 0
|
||||
assert "presets" in data
|
||||
assert len(data["presets"]) > 0
|
||||
|
||||
# Check preset structure
|
||||
preset = data['presets'][0]
|
||||
assert 'name' in preset
|
||||
assert 'start' in preset
|
||||
assert 'end' in preset
|
||||
assert 'mod' in preset
|
||||
preset = data["presets"][0]
|
||||
assert "name" in preset
|
||||
assert "start" in preset
|
||||
assert "end" in preset
|
||||
assert "mod" in preset
|
||||
|
||||
def test_scanner_stop_when_not_running(self, client):
|
||||
"""Test stopping scanner when not running."""
|
||||
response = client.post('/listening/scanner/stop')
|
||||
response = client.post("/receiver/scanner/stop")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'stopped'
|
||||
assert data["status"] == "stopped"
|
||||
|
||||
def test_activity_log(self, client):
|
||||
"""Test getting activity log."""
|
||||
response = client.get('/listening/scanner/log')
|
||||
response = client.get("/receiver/scanner/log")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert 'log' in data
|
||||
assert 'total' in data
|
||||
assert "log" in data
|
||||
assert "total" in data
|
||||
|
||||
def test_scanner_skip_when_not_running(self, client):
|
||||
"""Test skip signal when scanner not running returns error."""
|
||||
response = client.post('/listening/scanner/skip')
|
||||
response = client.post("/receiver/scanner/skip")
|
||||
assert response.status_code == 400
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'error'
|
||||
assert data["status"] == "error"
|
||||
|
||||
|
||||
class TestAudioEndpoints:
|
||||
@@ -333,58 +313,48 @@ class TestAudioEndpoints:
|
||||
|
||||
def test_audio_status(self, client):
|
||||
"""Test audio status endpoint."""
|
||||
response = client.get('/listening/audio/status')
|
||||
response = client.get("/receiver/audio/status")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert 'running' in data
|
||||
assert 'frequency' in data
|
||||
assert 'modulation' in data
|
||||
assert "running" in data
|
||||
assert "frequency" in data
|
||||
assert "modulation" in data
|
||||
|
||||
def test_audio_stop_when_not_running(self, client):
|
||||
"""Test stopping audio when not running."""
|
||||
response = client.post('/listening/audio/stop')
|
||||
response = client.post("/receiver/audio/stop")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'stopped'
|
||||
assert data["status"] == "stopped"
|
||||
|
||||
def test_audio_start_missing_frequency(self, client):
|
||||
"""Test starting audio without frequency returns error."""
|
||||
response = client.post(
|
||||
'/listening/audio/start',
|
||||
data=json.dumps({}),
|
||||
content_type='application/json'
|
||||
)
|
||||
response = client.post("/receiver/audio/start", data=json.dumps({}), content_type="application/json")
|
||||
assert response.status_code == 400
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'error'
|
||||
assert 'frequency' in data['message'].lower()
|
||||
assert data["status"] == "error"
|
||||
assert "frequency" in data["message"].lower()
|
||||
|
||||
def test_audio_start_invalid_modulation(self, client):
|
||||
"""Test starting audio with invalid modulation returns error."""
|
||||
response = client.post(
|
||||
'/listening/audio/start',
|
||||
data=json.dumps({
|
||||
'frequency': 98.1,
|
||||
'modulation': 'invalid_mode'
|
||||
}),
|
||||
content_type='application/json'
|
||||
"/receiver/audio/start",
|
||||
data=json.dumps({"frequency": 98.1, "modulation": "invalid_mode"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'error'
|
||||
assert 'modulation' in data['message'].lower()
|
||||
assert data["status"] == "error"
|
||||
assert "modulation" in data["message"].lower()
|
||||
|
||||
def test_audio_stream_when_not_running(self, client):
|
||||
"""Test audio stream when not running returns error."""
|
||||
response = client.get('/listening/audio/stream')
|
||||
assert response.status_code == 400
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'error'
|
||||
"""Test audio stream when not running returns empty response."""
|
||||
response = client.get("/receiver/audio/stream")
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
class TestExportEndpoints:
|
||||
@@ -392,36 +362,36 @@ class TestExportEndpoints:
|
||||
|
||||
def test_export_aircraft_json(self, client):
|
||||
"""Test exporting aircraft data as JSON."""
|
||||
response = client.get('/export/aircraft?format=json')
|
||||
response = client.get("/export/aircraft?format=json")
|
||||
assert response.status_code == 200
|
||||
assert response.content_type == 'application/json'
|
||||
assert response.content_type == "application/json"
|
||||
|
||||
def test_export_aircraft_csv(self, client):
|
||||
"""Test exporting aircraft data as CSV."""
|
||||
response = client.get('/export/aircraft?format=csv')
|
||||
response = client.get("/export/aircraft?format=csv")
|
||||
assert response.status_code == 200
|
||||
assert 'text/csv' in response.content_type
|
||||
assert "text/csv" in response.content_type
|
||||
|
||||
def test_export_wifi_json(self, client):
|
||||
"""Test exporting WiFi data as JSON."""
|
||||
response = client.get('/export/wifi?format=json')
|
||||
response = client.get("/export/wifi?format=json")
|
||||
assert response.status_code == 200
|
||||
assert response.content_type == 'application/json'
|
||||
assert response.content_type == "application/json"
|
||||
|
||||
def test_export_wifi_csv(self, client):
|
||||
"""Test exporting WiFi data as CSV."""
|
||||
response = client.get('/export/wifi?format=csv')
|
||||
response = client.get("/export/wifi?format=csv")
|
||||
assert response.status_code == 200
|
||||
assert 'text/csv' in response.content_type
|
||||
assert "text/csv" in response.content_type
|
||||
|
||||
def test_export_bluetooth_json(self, client):
|
||||
"""Test exporting Bluetooth data as JSON."""
|
||||
response = client.get('/export/bluetooth?format=json')
|
||||
response = client.get("/export/bluetooth?format=json")
|
||||
assert response.status_code == 200
|
||||
assert response.content_type == 'application/json'
|
||||
assert response.content_type == "application/json"
|
||||
|
||||
def test_export_bluetooth_csv(self, client):
|
||||
"""Test exporting Bluetooth data as CSV."""
|
||||
response = client.get('/export/bluetooth?format=csv')
|
||||
response = client.get("/export/bluetooth?format=csv")
|
||||
assert response.status_code == 200
|
||||
assert 'text/csv' in response.content_type
|
||||
assert "text/csv" in response.content_type
|
||||
|
||||
+64
-60
@@ -12,32 +12,36 @@ from routes.satellite import satellite_bp
|
||||
def app():
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(satellite_bp)
|
||||
app.config['TESTING'] = True
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def test_predict_passes_invalid_coords(client):
|
||||
"""Verify that invalid coordinates return a 400 error."""
|
||||
payload = {
|
||||
"latitude": 150.0, # Invalid (>90)
|
||||
"longitude": -0.1278
|
||||
"longitude": -0.1278,
|
||||
}
|
||||
response = client.post('/satellite/predict', json=payload)
|
||||
response = client.post("/satellite/predict", json=payload)
|
||||
assert response.status_code == 400
|
||||
assert response.json['status'] == 'error'
|
||||
assert response.json["status"] == "error"
|
||||
|
||||
|
||||
def test_fetch_celestrak_invalid_category(client):
|
||||
"""Verify that an unauthorized category is rejected."""
|
||||
response = client.get('/satellite/celestrak/category_fake')
|
||||
response = client.get("/satellite/celestrak/category_fake")
|
||||
assert response.status_code == 400
|
||||
assert response.json['status'] == 'error'
|
||||
assert 'Invalid category' in response.json['message']
|
||||
assert response.json["status"] == "error"
|
||||
assert "Invalid category" in response.json["message"]
|
||||
|
||||
|
||||
# Mocking Tests (External Calls and Skyfield)
|
||||
@patch('urllib.request.urlopen')
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_update_tle_success(mock_urlopen, client):
|
||||
"""Simulate a successful response from CelesTrak."""
|
||||
mock_content = (
|
||||
@@ -51,26 +55,24 @@ def test_update_tle_success(mock_urlopen, client):
|
||||
mock_response.__enter__.return_value = mock_response
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
response = client.post('/satellite/update-tle')
|
||||
response = client.post("/satellite/update-tle")
|
||||
assert response.status_code == 200
|
||||
assert response.json['status'] == 'success'
|
||||
assert 'ISS' in response.json['updated']
|
||||
assert response.json["status"] == "success"
|
||||
assert "ISS" in response.json["updated"]
|
||||
|
||||
@patch('skyfield.api.load')
|
||||
|
||||
@patch("skyfield.api.load")
|
||||
def test_get_satellite_position_skyfield_error(mock_load, client):
|
||||
"""Test behavior when Skyfield fails or data is missing."""
|
||||
# Force the timescale load to fail
|
||||
mock_load.side_effect = Exception("Skyfield error")
|
||||
|
||||
payload = {
|
||||
"latitude": 51.5,
|
||||
"longitude": -0.1,
|
||||
"satellites": ["ISS"]
|
||||
}
|
||||
response = client.post('/satellite/position', json=payload)
|
||||
payload = {"latitude": 51.5, "longitude": -0.1, "satellites": ["ISS"]}
|
||||
response = client.post("/satellite/position", json=payload)
|
||||
# Should return success but an empty positions list due to internal try-except
|
||||
assert response.status_code == 200
|
||||
assert response.json['positions'] == []
|
||||
assert response.json["positions"] == []
|
||||
|
||||
|
||||
def test_tracker_position_has_no_observer_fields():
|
||||
"""SSE tracker positions must NOT include observer-relative fields.
|
||||
@@ -83,9 +85,9 @@ def test_tracker_position_has_no_observer_fields():
|
||||
from routes.satellite import _start_satellite_tracker
|
||||
|
||||
ISS_TLE = (
|
||||
'ISS (ZARYA)',
|
||||
'1 25544U 98067A 24001.00000000 .00016717 00000-0 30171-3 0 9993',
|
||||
'2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123457',
|
||||
"ISS (ZARYA)",
|
||||
"1 25544U 98067A 24001.00000000 .00016717 00000-0 30171-3 0 9993",
|
||||
"2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123457",
|
||||
)
|
||||
|
||||
sat_q = queue.Queue(maxsize=5)
|
||||
@@ -93,54 +95,61 @@ def test_tracker_position_has_no_observer_fields():
|
||||
mock_app.satellite_queue = sat_q
|
||||
|
||||
from skyfield.api import load as _real_load
|
||||
|
||||
real_ts = _real_load.timescale(builtin=True)
|
||||
|
||||
# Pre-populate track cache so the tracker loop doesn't block computing 90 points
|
||||
tle_key = (ISS_TLE[0], ISS_TLE[1][:20])
|
||||
stub_track = [{'lat': 0.0, 'lon': float(i), 'past': i < 45} for i in range(91)]
|
||||
with patch('routes.satellite._tle_cache', {'ISS': ISS_TLE}), \
|
||||
patch('routes.satellite.get_tracked_satellites') as mock_tracked, \
|
||||
patch('routes.satellite._track_cache', {tle_key: (stub_track, 1e18)}), \
|
||||
patch('routes.satellite._get_timescale', return_value=real_ts), \
|
||||
patch.dict('sys.modules', {'app': mock_app}):
|
||||
mock_tracked.return_value = [{
|
||||
'name': 'ISS (ZARYA)', 'norad_id': 25544,
|
||||
'tle_line1': ISS_TLE[1], 'tle_line2': ISS_TLE[2],
|
||||
}]
|
||||
stub_track = [{"lat": 0.0, "lon": float(i), "past": i < 45} for i in range(91)]
|
||||
with (
|
||||
patch("routes.satellite._get_tle_cache", return_value={"ISS": ISS_TLE}),
|
||||
patch("routes.satellite.get_tracked_satellites") as mock_tracked,
|
||||
patch("routes.satellite._track_cache", {tle_key: (stub_track, 1e18)}),
|
||||
patch("routes.satellite._get_timescale", return_value=real_ts),
|
||||
patch.dict("sys.modules", {"app": mock_app}),
|
||||
):
|
||||
mock_tracked.return_value = [
|
||||
{
|
||||
"name": "ISS (ZARYA)",
|
||||
"norad_id": 25544,
|
||||
"tle_line1": ISS_TLE[1],
|
||||
"tle_line2": ISS_TLE[2],
|
||||
}
|
||||
]
|
||||
|
||||
t = threading.Thread(target=_start_satellite_tracker, daemon=True)
|
||||
t.start()
|
||||
msg = sat_q.get(timeout=10)
|
||||
|
||||
assert msg['type'] == 'positions'
|
||||
pos = msg['positions'][0]
|
||||
for forbidden in ('elevation', 'azimuth', 'distance', 'visible'):
|
||||
assert msg["type"] == "positions"
|
||||
pos = msg["positions"][0]
|
||||
for forbidden in ("elevation", "azimuth", "distance", "visible"):
|
||||
assert forbidden not in pos, f"SSE tracker must not emit '{forbidden}'"
|
||||
for required in ('lat', 'lon', 'altitude', 'satellite', 'norad_id'):
|
||||
for required in ("lat", "lon", "altitude", "satellite", "norad_id"):
|
||||
assert required in pos, f"SSE tracker must emit '{required}'"
|
||||
|
||||
|
||||
def test_predict_passes_currentpos_has_full_fields(client):
|
||||
"""currentPos in pass results must include altitude, elevation, azimuth, distance."""
|
||||
payload = {
|
||||
'latitude': 51.5074,
|
||||
'longitude': -0.1278,
|
||||
'hours': 48,
|
||||
'minEl': 5,
|
||||
'satellites': ['ISS'],
|
||||
"latitude": 51.5074,
|
||||
"longitude": -0.1278,
|
||||
"hours": 2,
|
||||
"minEl": 5,
|
||||
"satellites": ["ISS"],
|
||||
}
|
||||
response = client.post('/satellite/predict', json=payload)
|
||||
response = client.post("/satellite/predict", json=payload)
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data['status'] == 'success'
|
||||
if data['passes']:
|
||||
cp = data['passes'][0].get('currentPos', {})
|
||||
for field in ('lat', 'lon', 'altitude', 'elevation', 'azimuth', 'distance'):
|
||||
assert data["status"] == "success"
|
||||
if data["passes"]:
|
||||
cp = data["passes"][0].get("currentPos", {})
|
||||
for field in ("lat", "lon", "altitude", "elevation", "azimuth", "distance"):
|
||||
assert field in cp, f"currentPos missing field: {field}"
|
||||
|
||||
|
||||
@patch('routes.satellite.refresh_tle_data', return_value=['ISS'])
|
||||
@patch('routes.satellite._load_db_satellites_into_cache')
|
||||
@patch("routes.satellite.refresh_tle_data", return_value=["ISS"])
|
||||
@patch("routes.satellite._load_db_satellites_into_cache")
|
||||
def test_tle_auto_refresh_schedules_daily_repeat(mock_load_db, mock_refresh):
|
||||
"""After the first TLE refresh, a 24-hour follow-up timer must be scheduled."""
|
||||
import threading as real_threading
|
||||
@@ -158,28 +167,23 @@ def test_tle_auto_refresh_schedules_daily_repeat(mock_load_db, mock_refresh):
|
||||
if self._delay <= 5:
|
||||
self._fn()
|
||||
|
||||
with patch('routes.satellite.threading') as mock_threading:
|
||||
with patch("routes.satellite.threading") as mock_threading:
|
||||
mock_threading.Timer = CapturingTimer
|
||||
mock_threading.Thread = real_threading.Thread
|
||||
|
||||
from routes.satellite import init_tle_auto_refresh
|
||||
|
||||
init_tle_auto_refresh()
|
||||
|
||||
# First timer: startup delay (≤5s); second timer: 24h repeat (≥86400s)
|
||||
assert any(d <= 5 for d in scheduled_delays), \
|
||||
f"Expected startup delay timer; got delays: {scheduled_delays}"
|
||||
assert any(d >= 86400 for d in scheduled_delays), \
|
||||
f"Expected ~24h repeat timer; got delays: {scheduled_delays}"
|
||||
assert any(d <= 5 for d in scheduled_delays), f"Expected startup delay timer; got delays: {scheduled_delays}"
|
||||
assert any(d >= 86400 for d in scheduled_delays), f"Expected ~24h repeat timer; got delays: {scheduled_delays}"
|
||||
|
||||
|
||||
# Logic Integration Test (Simulating prediction)
|
||||
def test_predict_passes_empty_cache(client):
|
||||
"""Verify that if the satellite is not in cache, no passes are returned."""
|
||||
payload = {
|
||||
"latitude": 51.5,
|
||||
"longitude": -0.1,
|
||||
"satellites": ["SATELLITE_NON_EXISTENT"]
|
||||
}
|
||||
response = client.post('/satellite/predict', json=payload)
|
||||
payload = {"latitude": 51.5, "longitude": -0.1, "satellites": ["SATELLITE_NON_EXISTENT"]}
|
||||
response = client.post("/satellite/predict", json=payload)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json['passes']) == 0
|
||||
assert len(response.json["passes"]) == 0
|
||||
|
||||
@@ -7,94 +7,115 @@ import pytest
|
||||
def auth_client(client):
|
||||
"""Client with logged-in session."""
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
sess["logged_in"] = True
|
||||
return client
|
||||
|
||||
|
||||
def test_signal_guess_fm_broadcast(auth_client):
|
||||
"""FM broadcast frequency should return a known signal type."""
|
||||
resp = auth_client.post('/listening/signal/guess', json={
|
||||
'frequency_mhz': 98.1,
|
||||
'modulation': 'wfm',
|
||||
})
|
||||
resp = auth_client.post(
|
||||
"/receiver/signal/guess",
|
||||
json={
|
||||
"frequency_mhz": 98.1,
|
||||
"modulation": "wfm",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['status'] == 'ok'
|
||||
assert data['primary_label']
|
||||
assert data['confidence'] in ('HIGH', 'MEDIUM', 'LOW')
|
||||
assert data["status"] == "ok"
|
||||
assert data["primary_label"]
|
||||
assert data["confidence"] in ("HIGH", "MEDIUM", "LOW")
|
||||
|
||||
|
||||
def test_signal_guess_airband(auth_client):
|
||||
"""Airband frequency should be identified."""
|
||||
resp = auth_client.post('/listening/signal/guess', json={
|
||||
'frequency_mhz': 121.5,
|
||||
'modulation': 'am',
|
||||
})
|
||||
resp = auth_client.post(
|
||||
"/receiver/signal/guess",
|
||||
json={
|
||||
"frequency_mhz": 121.5,
|
||||
"modulation": "am",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['status'] == 'ok'
|
||||
assert data['primary_label']
|
||||
assert data["status"] == "ok"
|
||||
assert data["primary_label"]
|
||||
|
||||
|
||||
def test_signal_guess_ism_band(auth_client):
|
||||
"""ISM band frequency (433.92 MHz) should be identified."""
|
||||
resp = auth_client.post('/listening/signal/guess', json={
|
||||
'frequency_mhz': 433.92,
|
||||
})
|
||||
resp = auth_client.post(
|
||||
"/receiver/signal/guess",
|
||||
json={
|
||||
"frequency_mhz": 433.92,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['status'] == 'ok'
|
||||
assert data['primary_label']
|
||||
assert data['confidence'] in ('HIGH', 'MEDIUM', 'LOW')
|
||||
assert data["status"] == "ok"
|
||||
assert data["primary_label"]
|
||||
assert data["confidence"] in ("HIGH", "MEDIUM", "LOW")
|
||||
|
||||
|
||||
def test_signal_guess_missing_frequency(auth_client):
|
||||
"""Missing frequency should return 400."""
|
||||
resp = auth_client.post('/listening/signal/guess', json={})
|
||||
resp = auth_client.post("/receiver/signal/guess", json={})
|
||||
assert resp.status_code == 400
|
||||
data = resp.get_json()
|
||||
assert data['status'] == 'error'
|
||||
assert data["status"] == "error"
|
||||
|
||||
|
||||
def test_signal_guess_invalid_frequency(auth_client):
|
||||
"""Invalid frequency value should return 400."""
|
||||
resp = auth_client.post('/listening/signal/guess', json={
|
||||
'frequency_mhz': 'abc',
|
||||
})
|
||||
resp = auth_client.post(
|
||||
"/receiver/signal/guess",
|
||||
json={
|
||||
"frequency_mhz": "abc",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_signal_guess_negative_frequency(auth_client):
|
||||
"""Negative frequency should return 400."""
|
||||
resp = auth_client.post('/listening/signal/guess', json={
|
||||
'frequency_mhz': -5.0,
|
||||
})
|
||||
resp = auth_client.post(
|
||||
"/receiver/signal/guess",
|
||||
json={
|
||||
"frequency_mhz": -5.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_signal_guess_with_region(auth_client):
|
||||
"""Specifying region should work."""
|
||||
resp = auth_client.post('/listening/signal/guess', json={
|
||||
'frequency_mhz': 462.5625,
|
||||
'region': 'US',
|
||||
})
|
||||
resp = auth_client.post(
|
||||
"/receiver/signal/guess",
|
||||
json={
|
||||
"frequency_mhz": 462.5625,
|
||||
"region": "US",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['status'] == 'ok'
|
||||
assert data["status"] == "ok"
|
||||
|
||||
|
||||
def test_signal_guess_response_structure(auth_client):
|
||||
"""Response should have all expected fields."""
|
||||
resp = auth_client.post('/listening/signal/guess', json={
|
||||
'frequency_mhz': 146.52,
|
||||
'modulation': 'fm',
|
||||
})
|
||||
resp = auth_client.post(
|
||||
"/receiver/signal/guess",
|
||||
json={
|
||||
"frequency_mhz": 146.52,
|
||||
"modulation": "fm",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'primary_label' in data
|
||||
assert 'confidence' in data
|
||||
assert 'alternatives' in data
|
||||
assert 'explanation' in data
|
||||
assert 'tags' in data
|
||||
assert isinstance(data['alternatives'], list)
|
||||
assert isinstance(data['tags'], list)
|
||||
assert "primary_label" in data
|
||||
assert "confidence" in data
|
||||
assert "alternatives" in data
|
||||
assert "explanation" in data
|
||||
assert "tags" in data
|
||||
assert isinstance(data["alternatives"], list)
|
||||
assert isinstance(data["tags"], list)
|
||||
|
||||
+108
-90
@@ -8,52 +8,66 @@ from unittest.mock import MagicMock, patch
|
||||
def _login(client):
|
||||
"""Mark the Flask test session as authenticated."""
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
sess['username'] = 'test'
|
||||
sess['role'] = 'admin'
|
||||
sess["logged_in"] = True
|
||||
sess["username"] = "test"
|
||||
sess["role"] = "admin"
|
||||
|
||||
|
||||
def test_metrics_returns_expected_keys(client):
|
||||
"""GET /system/metrics returns top-level metric keys."""
|
||||
_login(client)
|
||||
resp = client.get('/system/metrics')
|
||||
resp = client.get("/system/metrics")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'system' in data
|
||||
assert 'processes' in data
|
||||
assert 'cpu' in data
|
||||
assert 'memory' in data
|
||||
assert 'disk' in data
|
||||
assert data['system']['hostname']
|
||||
assert 'version' in data['system']
|
||||
assert 'uptime_seconds' in data['system']
|
||||
assert 'uptime_human' in data['system']
|
||||
assert "system" in data
|
||||
assert "processes" in data
|
||||
assert "cpu" in data
|
||||
assert "memory" in data
|
||||
assert "disk" in data
|
||||
assert data["system"]["hostname"]
|
||||
assert "version" in data["system"]
|
||||
assert "uptime_seconds" in data["system"]
|
||||
assert "uptime_human" in data["system"]
|
||||
|
||||
|
||||
def test_metrics_enhanced_keys(client):
|
||||
"""GET /system/metrics returns enhanced metric keys."""
|
||||
_login(client)
|
||||
resp = client.get('/system/metrics')
|
||||
resp = client.get("/system/metrics")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
# New enhanced keys
|
||||
assert 'network' in data
|
||||
assert 'disk_io' in data
|
||||
assert 'boot_time' in data
|
||||
assert 'battery' in data
|
||||
assert 'fans' in data
|
||||
assert 'power' in data
|
||||
assert "network" in data
|
||||
assert "disk_io" in data
|
||||
assert "boot_time" in data
|
||||
assert "battery" in data
|
||||
assert "fans" in data
|
||||
assert "power" in data
|
||||
|
||||
# CPU should have per_core and freq
|
||||
if data['cpu'] is not None:
|
||||
assert 'per_core' in data['cpu']
|
||||
assert 'freq' in data['cpu']
|
||||
if data["cpu"] is not None:
|
||||
assert "per_core" in data["cpu"]
|
||||
assert "freq" in data["cpu"]
|
||||
|
||||
# Network should have interfaces and connections
|
||||
if data['network'] is not None:
|
||||
assert 'interfaces' in data['network']
|
||||
assert 'connections' in data['network']
|
||||
assert 'io' in data['network']
|
||||
if data["network"] is not None:
|
||||
assert "interfaces" in data["network"]
|
||||
assert "connections" in data["network"]
|
||||
assert "io" in data["network"]
|
||||
|
||||
|
||||
def test_throttle_flags_no_subprocess_without_vcgencmd():
|
||||
"""No subprocess is spawned when vcgencmd is not on PATH (non-Pi hosts).
|
||||
|
||||
The metrics collector thread runs for the whole process lifetime; if it
|
||||
spawns subprocesses on hosts without vcgencmd, those calls leak into
|
||||
other tests' subprocess mocks.
|
||||
"""
|
||||
import routes.system as mod
|
||||
|
||||
with patch("routes.system.shutil.which", return_value=None), patch("routes.system.subprocess.run") as mock_run:
|
||||
assert mod._collect_throttle_flags() is None
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
def test_metrics_without_psutil(client):
|
||||
@@ -64,18 +78,18 @@ def test_metrics_without_psutil(client):
|
||||
orig = mod._HAS_PSUTIL
|
||||
mod._HAS_PSUTIL = False
|
||||
try:
|
||||
resp = client.get('/system/metrics')
|
||||
resp = client.get("/system/metrics")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
# These fields should be None without psutil
|
||||
assert data['cpu'] is None
|
||||
assert data['memory'] is None
|
||||
assert data['disk'] is None
|
||||
assert data['network'] is None
|
||||
assert data['disk_io'] is None
|
||||
assert data['battery'] is None
|
||||
assert data['boot_time'] is None
|
||||
assert data['power'] is None
|
||||
assert data["cpu"] is None
|
||||
assert data["memory"] is None
|
||||
assert data["disk"] is None
|
||||
assert data["network"] is None
|
||||
assert data["disk_io"] is None
|
||||
assert data["battery"] is None
|
||||
assert data["boot_time"] is None
|
||||
assert data["power"] is None
|
||||
finally:
|
||||
mod._HAS_PSUTIL = orig
|
||||
|
||||
@@ -85,50 +99,50 @@ def test_sdr_devices_returns_list(client):
|
||||
_login(client)
|
||||
mock_device = MagicMock()
|
||||
mock_device.sdr_type = MagicMock()
|
||||
mock_device.sdr_type.value = 'rtlsdr'
|
||||
mock_device.sdr_type.value = "rtlsdr"
|
||||
mock_device.index = 0
|
||||
mock_device.name = 'Generic RTL2832U'
|
||||
mock_device.serial = '00000001'
|
||||
mock_device.driver = 'rtlsdr'
|
||||
mock_device.name = "Generic RTL2832U"
|
||||
mock_device.serial = "00000001"
|
||||
mock_device.driver = "rtlsdr"
|
||||
|
||||
with patch('utils.sdr.detection.detect_all_devices', return_value=[mock_device]):
|
||||
resp = client.get('/system/sdr_devices')
|
||||
with patch("utils.sdr.detection.detect_all_devices", return_value=[mock_device]):
|
||||
resp = client.get("/system/sdr_devices")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'devices' in data
|
||||
assert len(data['devices']) == 1
|
||||
assert data['devices'][0]['type'] == 'rtlsdr'
|
||||
assert data['devices'][0]['name'] == 'Generic RTL2832U'
|
||||
assert "devices" in data
|
||||
assert len(data["devices"]) == 1
|
||||
assert data["devices"][0]["type"] == "rtlsdr"
|
||||
assert data["devices"][0]["name"] == "Generic RTL2832U"
|
||||
|
||||
|
||||
def test_sdr_devices_handles_detection_failure(client):
|
||||
"""SDR detection failure returns empty list with error."""
|
||||
_login(client)
|
||||
with patch('utils.sdr.detection.detect_all_devices', side_effect=RuntimeError('no devices')):
|
||||
resp = client.get('/system/sdr_devices')
|
||||
with patch("utils.sdr.detection.detect_all_devices", side_effect=RuntimeError("no devices")):
|
||||
resp = client.get("/system/sdr_devices")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['devices'] == []
|
||||
assert 'error' in data
|
||||
assert data["devices"] == []
|
||||
assert "error" in data
|
||||
|
||||
|
||||
def test_stream_returns_sse_content_type(client):
|
||||
"""GET /system/stream returns text/event-stream."""
|
||||
_login(client)
|
||||
resp = client.get('/system/stream')
|
||||
resp = client.get("/system/stream")
|
||||
assert resp.status_code == 200
|
||||
assert 'text/event-stream' in resp.content_type
|
||||
assert "text/event-stream" in resp.content_type
|
||||
|
||||
|
||||
def test_location_returns_shape(client):
|
||||
"""GET /system/location returns lat/lon/source shape."""
|
||||
_login(client)
|
||||
resp = client.get('/system/location')
|
||||
resp = client.get("/system/location")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'lat' in data
|
||||
assert 'lon' in data
|
||||
assert 'source' in data
|
||||
assert "lat" in data
|
||||
assert "lon" in data
|
||||
assert "source" in data
|
||||
|
||||
|
||||
def test_location_from_gps(client):
|
||||
@@ -143,54 +157,55 @@ def test_location_from_gps(client):
|
||||
mock_pos.epy = 3.1
|
||||
mock_pos.altitude = 45.0
|
||||
|
||||
with patch('routes.system.get_current_position', return_value=mock_pos, create=True):
|
||||
with patch("routes.system.get_current_position", return_value=mock_pos, create=True):
|
||||
# Patch the import inside the function
|
||||
import routes.system as mod
|
||||
|
||||
original = mod._get_observer_location
|
||||
|
||||
def _patched():
|
||||
with patch('utils.gps.get_current_position', return_value=mock_pos):
|
||||
with patch("utils.gps.get_current_position", return_value=mock_pos):
|
||||
return original()
|
||||
|
||||
mod._get_observer_location = _patched
|
||||
try:
|
||||
resp = client.get('/system/location')
|
||||
resp = client.get("/system/location")
|
||||
finally:
|
||||
mod._get_observer_location = original
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['source'] == 'gps'
|
||||
assert data['lat'] == 51.5074
|
||||
assert data['lon'] == -0.1278
|
||||
assert data['gps']['fix_quality'] == 3
|
||||
assert data['gps']['satellites'] == 12
|
||||
assert data['gps']['accuracy'] == 3.1
|
||||
assert data['gps']['altitude'] == 45.0
|
||||
assert data["source"] == "gps"
|
||||
assert data["lat"] == 51.5074
|
||||
assert data["lon"] == -0.1278
|
||||
assert data["gps"]["fix_quality"] == 3
|
||||
assert data["gps"]["satellites"] == 12
|
||||
assert data["gps"]["accuracy"] == 3.1
|
||||
assert data["gps"]["altitude"] == 45.0
|
||||
|
||||
|
||||
def test_location_falls_back_to_defaults(client):
|
||||
"""Location endpoint returns constants defaults when GPS and config unavailable."""
|
||||
_login(client)
|
||||
resp = client.get('/system/location')
|
||||
resp = client.get("/system/location")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'source' in data
|
||||
assert "source" in data
|
||||
# Should get location from config or default constants
|
||||
assert data['lat'] is not None
|
||||
assert data['lon'] is not None
|
||||
assert data['source'] in ('config', 'default')
|
||||
assert data["lat"] is not None
|
||||
assert data["lon"] is not None
|
||||
assert data["source"] in ("config", "default")
|
||||
|
||||
|
||||
def test_weather_requires_location(client):
|
||||
"""Weather endpoint returns error when no location available."""
|
||||
_login(client)
|
||||
# Without lat/lon params and no GPS state or config
|
||||
resp = client.get('/system/weather')
|
||||
resp = client.get("/system/weather")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
# Either returns weather or error (depending on config)
|
||||
assert 'error' in data or 'temp_c' in data
|
||||
assert "error" in data or "temp_c" in data
|
||||
|
||||
|
||||
def test_weather_with_mocked_response(client):
|
||||
@@ -199,32 +214,35 @@ def test_weather_with_mocked_response(client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {
|
||||
'current_condition': [{
|
||||
'temp_C': '22',
|
||||
'temp_F': '72',
|
||||
'weatherDesc': [{'value': 'Clear'}],
|
||||
'humidity': '45',
|
||||
'windspeedMiles': '8',
|
||||
'winddir16Point': 'NW',
|
||||
'FeelsLikeC': '20',
|
||||
'visibility': '10',
|
||||
'pressure': '1013',
|
||||
}]
|
||||
"current_condition": [
|
||||
{
|
||||
"temp_C": "22",
|
||||
"temp_F": "72",
|
||||
"weatherDesc": [{"value": "Clear"}],
|
||||
"humidity": "45",
|
||||
"windspeedMiles": "8",
|
||||
"winddir16Point": "NW",
|
||||
"FeelsLikeC": "20",
|
||||
"visibility": "10",
|
||||
"pressure": "1013",
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
import routes.system as mod
|
||||
|
||||
# Clear cache
|
||||
mod._weather_cache.clear()
|
||||
mod._weather_cache_time = 0.0
|
||||
|
||||
with patch('routes.system._requests') as mock_requests:
|
||||
with patch("routes.system._requests") as mock_requests:
|
||||
mock_requests.get.return_value = mock_resp
|
||||
resp = client.get('/system/weather?lat=40.7&lon=-74.0')
|
||||
resp = client.get("/system/weather?lat=40.7&lon=-74.0")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['temp_c'] == '22'
|
||||
assert data['condition'] == 'Clear'
|
||||
assert data['humidity'] == '45'
|
||||
assert data['wind_mph'] == '8'
|
||||
assert data["temp_c"] == "22"
|
||||
assert data["condition"] == "Clear"
|
||||
assert data["humidity"] == "45"
|
||||
assert data["wind_mph"] == "8"
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Tests for the unified TLE store."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from utils import tle_store
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_db(tmp_path, monkeypatch):
|
||||
"""Point the store at a throwaway database file."""
|
||||
monkeypatch.setattr(tle_store, "_DB_PATH", tmp_path / "tle.db")
|
||||
tle_store._reset_for_tests()
|
||||
|
||||
|
||||
SAMPLE = (
|
||||
"ISS (ZARYA)",
|
||||
"1 25544U 98067A 23321.52083333 .00016717 00000-0 30171-3 0 9992",
|
||||
"2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123456",
|
||||
)
|
||||
|
||||
|
||||
class TestTLEStore:
|
||||
def test_seed_from_static_data(self):
|
||||
"""First access seeds from data/satellites.py TLE_SATELLITES."""
|
||||
tles = tle_store.all_tles()
|
||||
assert "ISS" in tles
|
||||
name, l1, l2 = tles["ISS"]
|
||||
assert l1.startswith("1 ")
|
||||
assert l2.startswith("2 ")
|
||||
|
||||
def test_update_and_get(self):
|
||||
tle_store.update({"TEST-SAT": SAMPLE})
|
||||
assert tle_store.get_tle("TEST-SAT") == SAMPLE
|
||||
|
||||
def test_get_missing_returns_none(self):
|
||||
assert tle_store.get_tle("NO-SUCH-SAT") is None
|
||||
|
||||
def test_update_overwrites(self):
|
||||
tle_store.update({"TEST-SAT": SAMPLE})
|
||||
newer = (SAMPLE[0], SAMPLE[1].replace("23321", "26100"), SAMPLE[2])
|
||||
tle_store.update({"TEST-SAT": newer})
|
||||
assert tle_store.get_tle("TEST-SAT") == newer
|
||||
|
||||
def test_persists_across_reset(self):
|
||||
"""Data survives a cache reset (i.e., it actually hit the database)."""
|
||||
tle_store.update({"TEST-SAT": SAMPLE})
|
||||
tle_store._reset_for_tests()
|
||||
assert tle_store.get_tle("TEST-SAT") == SAMPLE
|
||||
|
||||
def test_update_before_first_read_keeps_seed(self):
|
||||
"""An update() on a fresh DB must not prevent the static seed."""
|
||||
tle_store.update({"TEST-SAT": SAMPLE})
|
||||
tles = tle_store.all_tles()
|
||||
assert "TEST-SAT" in tles
|
||||
assert "ISS" in tles # static seed still present
|
||||
|
||||
def test_update_waits_for_concurrent_writer(self):
|
||||
"""A short-lived writer in another connection must not make update() raise."""
|
||||
import sqlite3
|
||||
import threading
|
||||
|
||||
tle_store.all_tles() # ensure DB exists
|
||||
blocker = sqlite3.connect(str(tle_store._DB_PATH), check_same_thread=False)
|
||||
blocker.execute("BEGIN IMMEDIATE")
|
||||
|
||||
def release_soon():
|
||||
import time
|
||||
|
||||
time.sleep(0.2)
|
||||
blocker.commit()
|
||||
blocker.close()
|
||||
|
||||
t = threading.Thread(target=release_soon)
|
||||
t.start()
|
||||
tle_store.update({"TEST-SAT": SAMPLE}) # must block briefly, not raise
|
||||
t.join()
|
||||
assert tle_store.get_tle("TEST-SAT") == SAMPLE
|
||||
|
||||
|
||||
def test_default_db_path_points_at_instance_dir():
|
||||
"""The unpatched module constant must resolve to <repo>/instance/tle.db."""
|
||||
import importlib
|
||||
|
||||
spec = importlib.util.find_spec("utils.tle_store")
|
||||
module_file = Path(spec.origin)
|
||||
expected = module_file.parent.parent / "instance" / "tle.db"
|
||||
# Read the constant from a fresh module instance, not the patched one
|
||||
fresh = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(fresh)
|
||||
assert expected == fresh._DB_PATH
|
||||
+170
-156
@@ -23,138 +23,147 @@ from utils.bluetooth.tracker_signatures import (
|
||||
# Apple AirTag advertisement payload samples
|
||||
AIRTAG_SAMPLES = [
|
||||
{
|
||||
'name': 'AirTag sample 1 - Find My advertisement',
|
||||
'address': 'AA:BB:CC:DD:EE:FF',
|
||||
'address_type': 'random',
|
||||
'manufacturer_id': APPLE_COMPANY_ID,
|
||||
'manufacturer_data': bytes.fromhex('121910deadbeef0123456789abcdef0123456789'),
|
||||
'service_uuids': ['fd6f'],
|
||||
'expected_type': TrackerType.AIRTAG,
|
||||
'expected_confidence': TrackerConfidence.HIGH,
|
||||
"name": "AirTag sample 1 - Find My advertisement",
|
||||
"address": "AA:BB:CC:DD:EE:FF",
|
||||
"address_type": "random",
|
||||
"manufacturer_id": APPLE_COMPANY_ID,
|
||||
"manufacturer_data": bytes.fromhex("121910deadbeef0123456789abcdef0123456789"),
|
||||
"service_uuids": ["fd6f"],
|
||||
"expected_type": TrackerType.AIRTAG,
|
||||
"expected_confidence": TrackerConfidence.HIGH,
|
||||
},
|
||||
{
|
||||
'name': 'AirTag sample 2 - Shorter payload',
|
||||
'address': '11:22:33:44:55:66',
|
||||
'address_type': 'rpa',
|
||||
'manufacturer_id': APPLE_COMPANY_ID,
|
||||
'manufacturer_data': bytes.fromhex('1219abcdef1234567890'),
|
||||
'service_uuids': [],
|
||||
'expected_type': TrackerType.AIRTAG,
|
||||
'expected_confidence': TrackerConfidence.MEDIUM,
|
||||
"name": "AirTag sample 2 - Shorter payload",
|
||||
"address": "11:22:33:44:55:66",
|
||||
"address_type": "rpa",
|
||||
"manufacturer_id": APPLE_COMPANY_ID,
|
||||
"manufacturer_data": bytes.fromhex("1219abcdef1234567890"),
|
||||
"service_uuids": [],
|
||||
"expected_type": TrackerType.AIRTAG,
|
||||
"expected_confidence": TrackerConfidence.MEDIUM,
|
||||
},
|
||||
]
|
||||
|
||||
# Apple Find My accessory (non-AirTag)
|
||||
FINDMY_ACCESSORY_SAMPLES = [
|
||||
{
|
||||
'name': 'Chipolo ONE Spot (Find My network)',
|
||||
'address': 'CC:DD:EE:FF:00:11',
|
||||
'address_type': 'random',
|
||||
'manufacturer_id': APPLE_COMPANY_ID,
|
||||
'manufacturer_data': bytes.fromhex('12cafe0123456789'),
|
||||
'service_uuids': ['fd6f'],
|
||||
'expected_type': TrackerType.AIRTAG, # Using Find My, detected as AirTag-like
|
||||
'expected_confidence': TrackerConfidence.HIGH,
|
||||
"name": "Chipolo ONE Spot (Find My network)",
|
||||
"address": "CC:DD:EE:FF:00:11",
|
||||
"address_type": "random",
|
||||
"manufacturer_id": APPLE_COMPANY_ID,
|
||||
"manufacturer_data": bytes.fromhex("12cafe0123456789"),
|
||||
"service_uuids": ["fd6f"],
|
||||
"expected_type": TrackerType.AIRTAG, # Using Find My, detected as AirTag-like
|
||||
"expected_confidence": TrackerConfidence.HIGH,
|
||||
},
|
||||
]
|
||||
|
||||
# Tile tracker samples
|
||||
TILE_SAMPLES = [
|
||||
{
|
||||
'name': 'Tile Mate - by company ID',
|
||||
'address': 'C4:E7:00:11:22:33',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': 0x00ED, # Tile Inc
|
||||
'manufacturer_data': bytes.fromhex('ed00aabbccdd'),
|
||||
'service_uuids': ['feed'],
|
||||
'expected_type': TrackerType.TILE,
|
||||
'expected_confidence': TrackerConfidence.HIGH,
|
||||
"name": "Tile Mate - by company ID",
|
||||
"address": "C4:E7:00:11:22:33",
|
||||
"address_type": "public",
|
||||
"manufacturer_id": 0x00ED, # Tile Inc
|
||||
"manufacturer_data": bytes.fromhex("ed00aabbccdd"),
|
||||
"service_uuids": ["feed"],
|
||||
"expected_type": TrackerType.TILE,
|
||||
"expected_confidence": TrackerConfidence.HIGH,
|
||||
},
|
||||
{
|
||||
'name': 'Tile Pro - by MAC prefix',
|
||||
'address': 'DC:54:AA:BB:CC:DD',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': None,
|
||||
'manufacturer_data': None,
|
||||
'service_uuids': ['feed'],
|
||||
'expected_type': TrackerType.TILE,
|
||||
'expected_confidence': TrackerConfidence.MEDIUM,
|
||||
"name": "Tile Pro - by MAC prefix",
|
||||
"address": "DC:54:AA:BB:CC:DD",
|
||||
"address_type": "public",
|
||||
"manufacturer_id": None,
|
||||
"manufacturer_data": None,
|
||||
"service_uuids": ["feed"],
|
||||
"expected_type": TrackerType.TILE,
|
||||
"expected_confidence": TrackerConfidence.MEDIUM,
|
||||
},
|
||||
{
|
||||
'name': 'Tile - by name only',
|
||||
'address': '00:11:22:33:44:55',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': None,
|
||||
'manufacturer_data': None,
|
||||
'service_uuids': [],
|
||||
'name': 'Tile Slim',
|
||||
'expected_type': TrackerType.TILE,
|
||||
'expected_confidence': TrackerConfidence.LOW,
|
||||
"name": "Tile - by name only",
|
||||
"address": "00:11:22:33:44:55",
|
||||
"address_type": "public",
|
||||
"manufacturer_id": None,
|
||||
"manufacturer_data": None,
|
||||
"service_uuids": [],
|
||||
"name": "Tile Slim",
|
||||
"expected_type": TrackerType.TILE,
|
||||
"expected_confidence": TrackerConfidence.LOW,
|
||||
},
|
||||
]
|
||||
|
||||
# Samsung SmartTag samples
|
||||
SAMSUNG_SAMPLES = [
|
||||
{
|
||||
'name': 'Samsung SmartTag - by company ID and service',
|
||||
'address': '58:4D:AA:BB:CC:DD',
|
||||
'address_type': 'random',
|
||||
'manufacturer_id': 0x0075, # Samsung
|
||||
'manufacturer_data': bytes.fromhex('75001234567890'),
|
||||
'service_uuids': ['fd5a'],
|
||||
'expected_type': TrackerType.SAMSUNG_SMARTTAG,
|
||||
'expected_confidence': TrackerConfidence.HIGH,
|
||||
"name": "Samsung SmartTag - by company ID and service",
|
||||
"address": "58:4D:AA:BB:CC:DD",
|
||||
"address_type": "random",
|
||||
"manufacturer_id": 0x0075, # Samsung
|
||||
"manufacturer_data": bytes.fromhex("75001234567890"),
|
||||
"service_uuids": ["fd5a"],
|
||||
"expected_type": TrackerType.SAMSUNG_SMARTTAG,
|
||||
"expected_confidence": TrackerConfidence.HIGH,
|
||||
},
|
||||
{
|
||||
'name': 'Samsung SmartTag - by MAC prefix only',
|
||||
'address': 'A0:75:BB:CC:DD:EE',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': None,
|
||||
'manufacturer_data': None,
|
||||
'service_uuids': [],
|
||||
'expected_type': TrackerType.SAMSUNG_SMARTTAG,
|
||||
'expected_confidence': TrackerConfidence.LOW,
|
||||
"name": "Samsung SmartTag - by MAC prefix only",
|
||||
"address": "A0:75:BB:CC:DD:EE",
|
||||
"address_type": "public",
|
||||
"manufacturer_id": None,
|
||||
"manufacturer_data": None,
|
||||
"service_uuids": [],
|
||||
"expected_type": TrackerType.SAMSUNG_SMARTTAG,
|
||||
"expected_confidence": TrackerConfidence.LOW,
|
||||
},
|
||||
]
|
||||
|
||||
# Non-tracker devices (should NOT be detected as trackers)
|
||||
NON_TRACKER_SAMPLES = [
|
||||
{
|
||||
'name': 'Apple AirPods - should not be tracker',
|
||||
'address': 'AA:BB:CC:DD:EE:00',
|
||||
'address_type': 'random',
|
||||
'manufacturer_id': APPLE_COMPANY_ID,
|
||||
'manufacturer_data': bytes.fromhex('100000'), # NOT Find My pattern
|
||||
'service_uuids': [],
|
||||
'expected_tracker': False,
|
||||
"name": "Apple AirPods - should not be tracker",
|
||||
"address": "AA:BB:CC:DD:EE:00",
|
||||
"address_type": "random",
|
||||
"manufacturer_id": APPLE_COMPANY_ID,
|
||||
"manufacturer_data": bytes.fromhex("100000"), # NOT Find My pattern
|
||||
"service_uuids": [],
|
||||
"expected_tracker": False,
|
||||
},
|
||||
{
|
||||
'name': 'Generic BLE device',
|
||||
'address': '00:11:22:33:44:55',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': 0x0006, # Microsoft
|
||||
'manufacturer_data': bytes.fromhex('0600aabbccdd'),
|
||||
'service_uuids': ['180f', '180a'], # Battery and Device Info services
|
||||
'expected_tracker': False,
|
||||
"name": "Generic BLE device",
|
||||
"address": "00:11:22:33:44:55",
|
||||
"address_type": "public",
|
||||
"manufacturer_id": 0x0006, # Microsoft
|
||||
"manufacturer_data": bytes.fromhex("0600aabbccdd"),
|
||||
"service_uuids": ["180f", "180a"], # Battery and Device Info services
|
||||
"expected_tracker": False,
|
||||
},
|
||||
{
|
||||
'name': 'Fitbit fitness tracker - not a location tracker',
|
||||
'address': 'FF:EE:DD:CC:BB:AA',
|
||||
'address_type': 'random',
|
||||
'manufacturer_id': 0x00D2, # Fitbit
|
||||
'manufacturer_data': bytes.fromhex('d2001234'),
|
||||
'service_uuids': ['adab'], # Fitbit service
|
||||
'expected_tracker': False,
|
||||
"name": "Fitbit fitness tracker - not a location tracker",
|
||||
"address": "FF:EE:DD:CC:BB:AA",
|
||||
"address_type": "random",
|
||||
"manufacturer_id": 0x00D2, # Fitbit
|
||||
"manufacturer_data": bytes.fromhex("d2001234"),
|
||||
"service_uuids": ["adab"], # Fitbit service
|
||||
"expected_tracker": False,
|
||||
},
|
||||
{
|
||||
'name': 'Bluetooth speaker',
|
||||
'address': '11:22:33:44:55:66',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': 0x0310, # Bose
|
||||
'manufacturer_data': None,
|
||||
'service_uuids': ['111e'], # Handsfree
|
||||
'name': 'Bose Speaker',
|
||||
'expected_tracker': False,
|
||||
"name": "Generic device with long payload - length alone is not evidence",
|
||||
"address": "22:33:44:55:66:77",
|
||||
"address_type": "public",
|
||||
"manufacturer_id": 0x0006, # Microsoft
|
||||
"manufacturer_data": bytes.fromhex("0600" + "ab" * 23), # 25 bytes
|
||||
"service_uuids": [],
|
||||
"expected_tracker": False,
|
||||
},
|
||||
{
|
||||
"name": "Bluetooth speaker",
|
||||
"address": "11:22:33:44:55:66",
|
||||
"address_type": "public",
|
||||
"manufacturer_id": 0x0310, # Bose
|
||||
"manufacturer_data": None,
|
||||
"service_uuids": ["111e"], # Handsfree
|
||||
"name": "Bose Speaker",
|
||||
"expected_tracker": False,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -163,6 +172,7 @@ NON_TRACKER_SAMPLES = [
|
||||
# TEST CASES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestTrackerDetection:
|
||||
"""Test tracker detection with sample payloads."""
|
||||
|
||||
@@ -173,80 +183,83 @@ class TestTrackerDetection:
|
||||
|
||||
# --- AirTag tests ---
|
||||
|
||||
@pytest.mark.parametrize('sample', AIRTAG_SAMPLES, ids=lambda s: s['name'])
|
||||
@pytest.mark.parametrize("sample", AIRTAG_SAMPLES, ids=lambda s: s["name"])
|
||||
def test_airtag_detection(self, engine, sample):
|
||||
"""Test AirTag detection with various payload samples."""
|
||||
result = engine.detect_tracker(
|
||||
address=sample['address'],
|
||||
address_type=sample['address_type'],
|
||||
name=sample.get('name'),
|
||||
manufacturer_id=sample['manufacturer_id'],
|
||||
manufacturer_data=sample['manufacturer_data'],
|
||||
service_uuids=sample['service_uuids'],
|
||||
address=sample["address"],
|
||||
address_type=sample["address_type"],
|
||||
name=sample.get("name"),
|
||||
manufacturer_id=sample["manufacturer_id"],
|
||||
manufacturer_data=sample["manufacturer_data"],
|
||||
service_uuids=sample["service_uuids"],
|
||||
)
|
||||
|
||||
assert result.is_tracker, f"Should detect {sample['name']} as tracker"
|
||||
assert result.tracker_type == sample['expected_type'], \
|
||||
assert result.tracker_type == sample["expected_type"], (
|
||||
f"Expected {sample['expected_type']}, got {result.tracker_type}"
|
||||
)
|
||||
# Allow medium when expecting high (degraded confidence is acceptable)
|
||||
if sample['expected_confidence'] == TrackerConfidence.HIGH:
|
||||
assert result.confidence in (TrackerConfidence.HIGH, TrackerConfidence.MEDIUM), \
|
||||
if sample["expected_confidence"] == TrackerConfidence.HIGH:
|
||||
assert result.confidence in (TrackerConfidence.HIGH, TrackerConfidence.MEDIUM), (
|
||||
f"Expected HIGH or MEDIUM confidence for {sample['name']}"
|
||||
)
|
||||
assert len(result.evidence) > 0, "Should provide evidence"
|
||||
|
||||
# --- Tile tests ---
|
||||
|
||||
@pytest.mark.parametrize('sample', TILE_SAMPLES, ids=lambda s: s['name'])
|
||||
@pytest.mark.parametrize("sample", TILE_SAMPLES, ids=lambda s: s["name"])
|
||||
def test_tile_detection(self, engine, sample):
|
||||
"""Test Tile tracker detection."""
|
||||
result = engine.detect_tracker(
|
||||
address=sample['address'],
|
||||
address_type=sample['address_type'],
|
||||
name=sample.get('name'),
|
||||
manufacturer_id=sample['manufacturer_id'],
|
||||
manufacturer_data=sample['manufacturer_data'],
|
||||
service_uuids=sample['service_uuids'],
|
||||
address=sample["address"],
|
||||
address_type=sample["address_type"],
|
||||
name=sample.get("name"),
|
||||
manufacturer_id=sample["manufacturer_id"],
|
||||
manufacturer_data=sample["manufacturer_data"],
|
||||
service_uuids=sample["service_uuids"],
|
||||
)
|
||||
|
||||
assert result.is_tracker, f"Should detect {sample['name']} as tracker"
|
||||
assert result.tracker_type == sample['expected_type'], \
|
||||
assert result.tracker_type == sample["expected_type"], (
|
||||
f"Expected {sample['expected_type']}, got {result.tracker_type}"
|
||||
)
|
||||
assert len(result.evidence) > 0, "Should provide evidence"
|
||||
|
||||
# --- Samsung SmartTag tests ---
|
||||
|
||||
@pytest.mark.parametrize('sample', SAMSUNG_SAMPLES, ids=lambda s: s['name'])
|
||||
@pytest.mark.parametrize("sample", SAMSUNG_SAMPLES, ids=lambda s: s["name"])
|
||||
def test_samsung_smarttag_detection(self, engine, sample):
|
||||
"""Test Samsung SmartTag detection."""
|
||||
result = engine.detect_tracker(
|
||||
address=sample['address'],
|
||||
address_type=sample['address_type'],
|
||||
name=sample.get('name'),
|
||||
manufacturer_id=sample['manufacturer_id'],
|
||||
manufacturer_data=sample['manufacturer_data'],
|
||||
service_uuids=sample['service_uuids'],
|
||||
address=sample["address"],
|
||||
address_type=sample["address_type"],
|
||||
name=sample.get("name"),
|
||||
manufacturer_id=sample["manufacturer_id"],
|
||||
manufacturer_data=sample["manufacturer_data"],
|
||||
service_uuids=sample["service_uuids"],
|
||||
)
|
||||
|
||||
assert result.is_tracker, f"Should detect {sample['name']} as tracker"
|
||||
assert result.tracker_type == sample['expected_type'], \
|
||||
assert result.tracker_type == sample["expected_type"], (
|
||||
f"Expected {sample['expected_type']}, got {result.tracker_type}"
|
||||
)
|
||||
|
||||
# --- Non-tracker tests (negative cases) ---
|
||||
|
||||
@pytest.mark.parametrize('sample', NON_TRACKER_SAMPLES, ids=lambda s: s['name'])
|
||||
@pytest.mark.parametrize("sample", NON_TRACKER_SAMPLES, ids=lambda s: s["name"])
|
||||
def test_non_tracker_not_detected(self, engine, sample):
|
||||
"""Test that non-tracker devices are NOT falsely detected."""
|
||||
result = engine.detect_tracker(
|
||||
address=sample['address'],
|
||||
address_type=sample['address_type'],
|
||||
name=sample.get('name'),
|
||||
manufacturer_id=sample['manufacturer_id'],
|
||||
manufacturer_data=sample['manufacturer_data'],
|
||||
service_uuids=sample['service_uuids'],
|
||||
address=sample["address"],
|
||||
address_type=sample["address_type"],
|
||||
name=sample.get("name"),
|
||||
manufacturer_id=sample["manufacturer_id"],
|
||||
manufacturer_data=sample["manufacturer_data"],
|
||||
service_uuids=sample["service_uuids"],
|
||||
)
|
||||
|
||||
assert not result.is_tracker, \
|
||||
f"{sample['name']} should NOT be detected as tracker (got: {result.tracker_type})"
|
||||
assert not result.is_tracker, f"{sample['name']} should NOT be detected as tracker (got: {result.tracker_type})"
|
||||
|
||||
|
||||
class TestFingerprinting:
|
||||
@@ -260,32 +273,31 @@ class TestFingerprinting:
|
||||
"""Test that same payload produces same fingerprint."""
|
||||
fp1 = engine.generate_device_fingerprint(
|
||||
manufacturer_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data=bytes.fromhex('1219deadbeef'),
|
||||
service_uuids=['fd6f'],
|
||||
manufacturer_data=bytes.fromhex("1219deadbeef"),
|
||||
service_uuids=["fd6f"],
|
||||
service_data={},
|
||||
tx_power=-10,
|
||||
name='TestDevice',
|
||||
name="TestDevice",
|
||||
)
|
||||
|
||||
fp2 = engine.generate_device_fingerprint(
|
||||
manufacturer_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data=bytes.fromhex('1219deadbeef'),
|
||||
service_uuids=['fd6f'],
|
||||
manufacturer_data=bytes.fromhex("1219deadbeef"),
|
||||
service_uuids=["fd6f"],
|
||||
service_data={},
|
||||
tx_power=-10,
|
||||
name='TestDevice',
|
||||
name="TestDevice",
|
||||
)
|
||||
|
||||
assert fp1.fingerprint_id == fp2.fingerprint_id, \
|
||||
"Same payload should produce same fingerprint"
|
||||
assert fp1.fingerprint_id == fp2.fingerprint_id, "Same payload should produce same fingerprint"
|
||||
|
||||
def test_fingerprint_different_mac(self, engine):
|
||||
"""Test that fingerprint ignores MAC address (for tracking across rotations)."""
|
||||
# Fingerprinting doesn't take MAC as input, so this tests the concept
|
||||
fp1 = engine.generate_device_fingerprint(
|
||||
manufacturer_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data=bytes.fromhex('1219abcdef'),
|
||||
service_uuids=['fd6f'],
|
||||
manufacturer_data=bytes.fromhex("1219abcdef"),
|
||||
service_uuids=["fd6f"],
|
||||
service_data={},
|
||||
tx_power=None,
|
||||
name=None,
|
||||
@@ -294,8 +306,8 @@ class TestFingerprinting:
|
||||
# Same payload characteristics should produce same fingerprint
|
||||
fp2 = engine.generate_device_fingerprint(
|
||||
manufacturer_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data=bytes.fromhex('1219abcdef'),
|
||||
service_uuids=['fd6f'],
|
||||
manufacturer_data=bytes.fromhex("1219abcdef"),
|
||||
service_uuids=["fd6f"],
|
||||
service_data={},
|
||||
tx_power=None,
|
||||
name=None,
|
||||
@@ -308,11 +320,11 @@ class TestFingerprinting:
|
||||
# Rich payload = high stability
|
||||
fp_rich = engine.generate_device_fingerprint(
|
||||
manufacturer_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data=bytes.fromhex('1219aabbccdd'),
|
||||
service_uuids=['fd6f', '180f'],
|
||||
service_data={'fd6f': bytes.fromhex('01')},
|
||||
manufacturer_data=bytes.fromhex("1219aabbccdd"),
|
||||
service_uuids=["fd6f", "180f"],
|
||||
service_data={"fd6f": bytes.fromhex("01")},
|
||||
tx_power=-5,
|
||||
name='AirTag',
|
||||
name="AirTag",
|
||||
)
|
||||
|
||||
# Minimal payload = low stability
|
||||
@@ -325,8 +337,9 @@ class TestFingerprinting:
|
||||
name=None,
|
||||
)
|
||||
|
||||
assert fp_rich.stability_confidence > fp_minimal.stability_confidence, \
|
||||
assert fp_rich.stability_confidence > fp_minimal.stability_confidence, (
|
||||
"Rich payload should have higher stability confidence"
|
||||
)
|
||||
|
||||
|
||||
class TestSuspiciousPresence:
|
||||
@@ -339,7 +352,7 @@ class TestSuspiciousPresence:
|
||||
def test_risk_score_for_tracker(self, engine):
|
||||
"""Test that trackers get base risk score."""
|
||||
risk_score, risk_factors = engine.evaluate_suspicious_presence(
|
||||
fingerprint_id='test123',
|
||||
fingerprint_id="test123",
|
||||
is_tracker=True,
|
||||
seen_count=5,
|
||||
duration_seconds=60,
|
||||
@@ -349,12 +362,12 @@ class TestSuspiciousPresence:
|
||||
)
|
||||
|
||||
assert risk_score >= 0.3, "Tracker should have base risk score"
|
||||
assert any('tracker' in f.lower() for f in risk_factors)
|
||||
assert any("tracker" in f.lower() for f in risk_factors)
|
||||
|
||||
def test_risk_score_for_persistent_tracker(self, engine):
|
||||
"""Test that persistent tracker presence increases risk."""
|
||||
risk_score, risk_factors = engine.evaluate_suspicious_presence(
|
||||
fingerprint_id='test456',
|
||||
fingerprint_id="test456",
|
||||
is_tracker=True,
|
||||
seen_count=50,
|
||||
duration_seconds=900, # 15 minutes
|
||||
@@ -369,7 +382,7 @@ class TestSuspiciousPresence:
|
||||
def test_non_tracker_low_risk(self, engine):
|
||||
"""Test that non-trackers have low risk scores."""
|
||||
risk_score, risk_factors = engine.evaluate_suspicious_presence(
|
||||
fingerprint_id='test789',
|
||||
fingerprint_id="test789",
|
||||
is_tracker=False,
|
||||
seen_count=5,
|
||||
duration_seconds=60,
|
||||
@@ -387,11 +400,11 @@ class TestConvenienceFunction:
|
||||
def test_detect_tracker_function(self):
|
||||
"""Test the detect_tracker() convenience function."""
|
||||
result = detect_tracker(
|
||||
address='C4:E7:11:22:33:44',
|
||||
address_type='public',
|
||||
name='Tile Mate',
|
||||
address="C4:E7:11:22:33:44",
|
||||
address_type="public",
|
||||
name="Tile Mate",
|
||||
manufacturer_id=0x00ED,
|
||||
service_uuids=['feed'],
|
||||
service_uuids=["feed"],
|
||||
)
|
||||
|
||||
assert result.is_tracker
|
||||
@@ -408,6 +421,7 @@ class TestConvenienceFunction:
|
||||
# SMOKE TEST FOR API ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_api_backwards_compatibility():
|
||||
"""
|
||||
Smoke test checklist for API backwards compatibility.
|
||||
@@ -439,5 +453,5 @@ def test_api_backwards_compatibility():
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
+26
-31
@@ -16,23 +16,23 @@ class TestFrequencyValidation:
|
||||
|
||||
def test_valid_frequencies(self):
|
||||
"""Test valid frequency values."""
|
||||
assert validate_frequency('152.0') == '152.0'
|
||||
assert validate_frequency(152.0) == '152.0'
|
||||
assert validate_frequency('1090') == '1090'
|
||||
assert validate_frequency(433.92) == '433.92'
|
||||
assert validate_frequency("152.0") == 152.0
|
||||
assert validate_frequency(152.0) == 152.0
|
||||
assert validate_frequency("1090") == 1090.0
|
||||
assert validate_frequency(433.92) == 433.92
|
||||
|
||||
def test_frequency_range(self):
|
||||
"""Test frequency range limits."""
|
||||
# RTL-SDR typical range: 24MHz - 1766MHz
|
||||
assert validate_frequency('24') == '24'
|
||||
assert validate_frequency('1700') == '1700'
|
||||
assert validate_frequency("24") == 24.0
|
||||
assert validate_frequency("1700") == 1700.0
|
||||
|
||||
def test_invalid_frequencies(self):
|
||||
"""Test invalid frequency values."""
|
||||
with pytest.raises(ValueError):
|
||||
validate_frequency('')
|
||||
validate_frequency("")
|
||||
with pytest.raises(ValueError):
|
||||
validate_frequency('abc')
|
||||
validate_frequency("abc")
|
||||
with pytest.raises(ValueError):
|
||||
validate_frequency(-100)
|
||||
with pytest.raises(ValueError):
|
||||
@@ -44,19 +44,16 @@ class TestGainValidation:
|
||||
|
||||
def test_valid_gains(self):
|
||||
"""Test valid gain values."""
|
||||
assert validate_gain('0') == '0'
|
||||
assert validate_gain('40') == '40'
|
||||
assert validate_gain(49.6) == '49.6'
|
||||
assert validate_gain('auto') == 'auto'
|
||||
assert validate_gain("0") == 0.0
|
||||
assert validate_gain("40") == 40.0
|
||||
assert validate_gain(49.6) == 49.6
|
||||
|
||||
def test_invalid_gains(self):
|
||||
"""Test invalid gain values."""
|
||||
with pytest.raises(ValueError):
|
||||
validate_gain(-10)
|
||||
with pytest.raises(ValueError):
|
||||
validate_gain(100)
|
||||
with pytest.raises(ValueError):
|
||||
validate_gain('invalid')
|
||||
validate_gain("invalid")
|
||||
|
||||
|
||||
class TestDeviceIndexValidation:
|
||||
@@ -64,19 +61,17 @@ class TestDeviceIndexValidation:
|
||||
|
||||
def test_valid_indices(self):
|
||||
"""Test valid device indices."""
|
||||
assert validate_device_index('0') == '0'
|
||||
assert validate_device_index(0) == '0'
|
||||
assert validate_device_index('1') == '1'
|
||||
assert validate_device_index(3) == '3'
|
||||
assert validate_device_index("0") == 0
|
||||
assert validate_device_index(0) == 0
|
||||
assert validate_device_index("1") == 1
|
||||
assert validate_device_index(3) == 3
|
||||
|
||||
def test_invalid_indices(self):
|
||||
"""Test invalid device indices."""
|
||||
with pytest.raises(ValueError):
|
||||
validate_device_index(-1)
|
||||
with pytest.raises(ValueError):
|
||||
validate_device_index('abc')
|
||||
with pytest.raises(ValueError):
|
||||
validate_device_index(100)
|
||||
validate_device_index("abc")
|
||||
|
||||
|
||||
class TestRtlTcpHostValidation:
|
||||
@@ -84,19 +79,19 @@ class TestRtlTcpHostValidation:
|
||||
|
||||
def test_valid_hosts(self):
|
||||
"""Test valid host values."""
|
||||
assert validate_rtl_tcp_host('localhost') == 'localhost'
|
||||
assert validate_rtl_tcp_host('127.0.0.1') == '127.0.0.1'
|
||||
assert validate_rtl_tcp_host('192.168.1.1') == '192.168.1.1'
|
||||
assert validate_rtl_tcp_host('server.example.com') == 'server.example.com'
|
||||
assert validate_rtl_tcp_host("localhost") == "localhost"
|
||||
assert validate_rtl_tcp_host("127.0.0.1") == "127.0.0.1"
|
||||
assert validate_rtl_tcp_host("192.168.1.1") == "192.168.1.1"
|
||||
assert validate_rtl_tcp_host("server.example.com") == "server.example.com"
|
||||
|
||||
def test_invalid_hosts(self):
|
||||
"""Test invalid host values."""
|
||||
with pytest.raises(ValueError):
|
||||
validate_rtl_tcp_host('')
|
||||
validate_rtl_tcp_host("")
|
||||
with pytest.raises(ValueError):
|
||||
validate_rtl_tcp_host('invalid host with spaces')
|
||||
validate_rtl_tcp_host("invalid host with spaces")
|
||||
with pytest.raises(ValueError):
|
||||
validate_rtl_tcp_host('host;rm -rf /')
|
||||
validate_rtl_tcp_host("host;rm -rf /")
|
||||
|
||||
|
||||
class TestRtlTcpPortValidation:
|
||||
@@ -105,7 +100,7 @@ class TestRtlTcpPortValidation:
|
||||
def test_valid_ports(self):
|
||||
"""Test valid port values."""
|
||||
assert validate_rtl_tcp_port(1234) == 1234
|
||||
assert validate_rtl_tcp_port('1234') == 1234
|
||||
assert validate_rtl_tcp_port("1234") == 1234
|
||||
assert validate_rtl_tcp_port(30003) == 30003
|
||||
assert validate_rtl_tcp_port(65535) == 65535
|
||||
|
||||
@@ -118,4 +113,4 @@ class TestRtlTcpPortValidation:
|
||||
with pytest.raises(ValueError):
|
||||
validate_rtl_tcp_port(70000)
|
||||
with pytest.raises(ValueError):
|
||||
validate_rtl_tcp_port('abc')
|
||||
validate_rtl_tcp_port("abc")
|
||||
|
||||
+49
-32
@@ -9,73 +9,90 @@ import pytest
|
||||
def auth_client(client):
|
||||
"""Client with logged-in session."""
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
sess["logged_in"] = True
|
||||
return client
|
||||
|
||||
|
||||
def test_waterfall_start_no_rtl_power(auth_client):
|
||||
"""Start should fail gracefully when rtl_power is not available."""
|
||||
with patch('routes.listening_post.find_rtl_power', return_value=None):
|
||||
resp = auth_client.post('/listening/waterfall/start', json={
|
||||
'start_freq': 88.0,
|
||||
'end_freq': 108.0,
|
||||
})
|
||||
with patch("routes.listening_post.waterfall.find_rtl_power", return_value=None):
|
||||
resp = auth_client.post(
|
||||
"/receiver/waterfall/start",
|
||||
json={
|
||||
"start_freq": 88.0,
|
||||
"end_freq": 108.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
data = resp.get_json()
|
||||
assert 'rtl_power' in data['message']
|
||||
assert "rtl_power" in data["message"]
|
||||
|
||||
|
||||
def test_waterfall_start_invalid_range(auth_client):
|
||||
"""Start should reject end <= start."""
|
||||
with patch('routes.listening_post.find_rtl_power', return_value='/usr/bin/rtl_power'):
|
||||
resp = auth_client.post('/listening/waterfall/start', json={
|
||||
'start_freq': 108.0,
|
||||
'end_freq': 88.0,
|
||||
})
|
||||
with patch("routes.listening_post.waterfall.find_rtl_power", return_value="/usr/bin/rtl_power"):
|
||||
resp = auth_client.post(
|
||||
"/receiver/waterfall/start",
|
||||
json={
|
||||
"start_freq": 108.0,
|
||||
"end_freq": 88.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_waterfall_start_success(auth_client):
|
||||
"""Start should succeed with mocked rtl_power and device."""
|
||||
with patch('routes.listening_post.find_rtl_power', return_value='/usr/bin/rtl_power'), \
|
||||
patch('routes.listening_post.app_module') as mock_app:
|
||||
with (
|
||||
patch("routes.listening_post.waterfall.find_rtl_power", return_value="/usr/bin/rtl_power"),
|
||||
patch("routes.listening_post.waterfall.app_module") as mock_app,
|
||||
):
|
||||
mock_app.claim_sdr_device.return_value = None # No error, claim succeeds
|
||||
resp = auth_client.post('/listening/waterfall/start', json={
|
||||
'start_freq': 88.0,
|
||||
'end_freq': 108.0,
|
||||
'gain': 40,
|
||||
'device': 0,
|
||||
})
|
||||
resp = auth_client.post(
|
||||
"/receiver/waterfall/start",
|
||||
json={
|
||||
"start_freq": 88.0,
|
||||
"end_freq": 108.0,
|
||||
"gain": 40,
|
||||
"device": 0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['status'] == 'started'
|
||||
assert data["status"] == "started"
|
||||
|
||||
# Clean up: stop waterfall
|
||||
import routes.listening_post as lp
|
||||
|
||||
lp.waterfall_running = False
|
||||
|
||||
|
||||
def test_waterfall_stop(auth_client):
|
||||
"""Stop should succeed."""
|
||||
resp = auth_client.post('/listening/waterfall/stop')
|
||||
resp = auth_client.post("/receiver/waterfall/stop")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['status'] == 'stopped'
|
||||
assert data["status"] == "stopped"
|
||||
|
||||
|
||||
def test_waterfall_stream_mimetype(auth_client):
|
||||
"""Stream should return event-stream content type."""
|
||||
resp = auth_client.get('/listening/waterfall/stream')
|
||||
assert resp.content_type.startswith('text/event-stream')
|
||||
resp = auth_client.get("/receiver/waterfall/stream")
|
||||
assert resp.content_type.startswith("text/event-stream")
|
||||
|
||||
|
||||
def test_waterfall_start_device_busy(auth_client):
|
||||
"""Start should fail when device is in use."""
|
||||
with patch('routes.listening_post.find_rtl_power', return_value='/usr/bin/rtl_power'), \
|
||||
patch('routes.listening_post.app_module') as mock_app:
|
||||
mock_app.claim_sdr_device.return_value = 'SDR device 0 is in use by scanner'
|
||||
resp = auth_client.post('/listening/waterfall/start', json={
|
||||
'start_freq': 88.0,
|
||||
'end_freq': 108.0,
|
||||
})
|
||||
with (
|
||||
patch("routes.listening_post.waterfall.find_rtl_power", return_value="/usr/bin/rtl_power"),
|
||||
patch("routes.listening_post.waterfall.app_module") as mock_app,
|
||||
):
|
||||
mock_app.claim_sdr_device.return_value = "SDR device 0 is in use by scanner"
|
||||
resp = auth_client.post(
|
||||
"/receiver/waterfall/start",
|
||||
json={
|
||||
"start_freq": 88.0,
|
||||
"end_freq": 108.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
+262
-220
@@ -6,11 +6,14 @@ and image handling.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from utils.weather_sat import (
|
||||
WEATHER_SATELLITES,
|
||||
CaptureProgress,
|
||||
@@ -21,34 +24,57 @@ from utils.weather_sat import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stop_decoder_threads():
|
||||
"""Stop watcher/reader threads leaked by tests that call start().
|
||||
|
||||
Leaked threads keep scanning the output dir and contend for the SQLite
|
||||
lock, slowing every later test in the session. Full stop() is unsafe
|
||||
here: it would os.close() the mocked pty fds (10, 11), which are real
|
||||
fds of the pytest process.
|
||||
"""
|
||||
created: list[WeatherSatDecoder] = []
|
||||
orig_init = WeatherSatDecoder.__init__
|
||||
|
||||
def tracking_init(self, *args, **kwargs):
|
||||
orig_init(self, *args, **kwargs)
|
||||
created.append(self)
|
||||
|
||||
with patch.object(WeatherSatDecoder, "__init__", tracking_init):
|
||||
yield
|
||||
for decoder in created:
|
||||
decoder._running = False
|
||||
decoder._stop_event.set()
|
||||
|
||||
|
||||
class TestWeatherSatDecoder:
|
||||
"""Tests for WeatherSatDecoder class."""
|
||||
|
||||
def test_decoder_initialization(self):
|
||||
"""Decoder should initialize with default output directory."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
assert decoder.is_running is False
|
||||
assert decoder.decoder_available == 'satdump'
|
||||
assert decoder.current_satellite == ''
|
||||
assert decoder.decoder_available == "satdump"
|
||||
assert decoder.current_satellite == ""
|
||||
assert decoder.current_frequency == 0.0
|
||||
|
||||
def test_decoder_initialization_no_satdump(self):
|
||||
"""Decoder should detect when SatDump is unavailable."""
|
||||
with patch('shutil.which', return_value=None):
|
||||
with patch("shutil.which", return_value=None):
|
||||
decoder = WeatherSatDecoder()
|
||||
assert decoder.decoder_available is None
|
||||
|
||||
def test_decoder_custom_output_dir(self):
|
||||
"""Decoder should accept custom output directory."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
custom_dir = '/tmp/custom_output'
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
custom_dir = "/tmp/custom_output"
|
||||
decoder = WeatherSatDecoder(output_dir=custom_dir)
|
||||
assert decoder._output_dir == Path(custom_dir)
|
||||
|
||||
def test_set_callback(self):
|
||||
"""Decoder should accept progress callback."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
@@ -56,7 +82,7 @@ class TestWeatherSatDecoder:
|
||||
|
||||
def test_set_on_complete(self):
|
||||
"""Decoder should accept on_complete callback."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
callback = MagicMock()
|
||||
decoder.set_on_complete(callback)
|
||||
@@ -64,44 +90,47 @@ class TestWeatherSatDecoder:
|
||||
|
||||
def test_start_no_decoder(self):
|
||||
"""start() should fail when no decoder available."""
|
||||
with patch('shutil.which', return_value=None):
|
||||
with patch("shutil.which", return_value=None):
|
||||
decoder = WeatherSatDecoder()
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success, error_msg = 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'
|
||||
assert 'SatDump' in progress.message
|
||||
assert progress.status == "error"
|
||||
assert "SatDump" in progress.message
|
||||
|
||||
def test_start_invalid_satellite(self):
|
||||
"""start() should fail with invalid satellite."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success, error_msg = 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()
|
||||
progress = callback.call_args[0][0]
|
||||
assert progress.status == 'error'
|
||||
assert 'Unknown satellite' in progress.message
|
||||
assert progress.status == "error"
|
||||
assert "Unknown satellite" in progress.message
|
||||
|
||||
@patch('subprocess.Popen')
|
||||
@patch('pty.openpty')
|
||||
@patch('utils.weather_sat.register_process')
|
||||
@patch("subprocess.Popen")
|
||||
@patch("pty.openpty")
|
||||
@patch("utils.weather_sat.register_process")
|
||||
def test_start_success(self, mock_register, mock_pty, mock_popen):
|
||||
"""start() should successfully start SatDump."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'), \
|
||||
patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'):
|
||||
|
||||
mock_pty.return_value = (10, 11)
|
||||
with (
|
||||
patch("shutil.which", return_value="/usr/bin/satdump"),
|
||||
patch("utils.weather_sat.WeatherSatDecoder._resolve_device_id", return_value="0"),
|
||||
):
|
||||
# Use a real pipe — hardcoded fds like (10, 11) collide with fds
|
||||
# the test process actually has open (DB, log files, capture)
|
||||
mock_pty.return_value = os.pipe()
|
||||
mock_process = MagicMock()
|
||||
mock_process.poll.return_value = None
|
||||
mock_popen.return_value = mock_process
|
||||
@@ -111,7 +140,7 @@ class TestWeatherSatDecoder:
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success, error_msg = decoder.start(
|
||||
satellite='NOAA-18',
|
||||
satellite="NOAA-18",
|
||||
device_index=0,
|
||||
gain=40.0,
|
||||
bias_t=True,
|
||||
@@ -120,25 +149,27 @@ 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_satellite == "NOAA-18"
|
||||
assert decoder.current_frequency == 137.9125
|
||||
assert decoder.current_mode == 'APT'
|
||||
assert decoder.current_mode == "APT"
|
||||
assert decoder.device_index == 0
|
||||
|
||||
mock_popen.assert_called_once()
|
||||
cmd = mock_popen.call_args[0][0]
|
||||
assert cmd[0] == 'satdump'
|
||||
assert 'live' in cmd
|
||||
assert 'noaa_apt' in cmd
|
||||
assert '--bias' in cmd
|
||||
assert cmd[0] == "satdump"
|
||||
assert "live" in cmd
|
||||
assert "noaa_apt" in cmd
|
||||
assert "--bias" in cmd
|
||||
|
||||
@patch('subprocess.Popen')
|
||||
@patch('pty.openpty')
|
||||
@patch('utils.weather_sat.register_process')
|
||||
@patch("subprocess.Popen")
|
||||
@patch("pty.openpty")
|
||||
@patch("utils.weather_sat.register_process")
|
||||
def test_start_rtl_tcp_uses_rtltcp_source(self, mock_register, mock_pty, mock_popen):
|
||||
"""start() with rtl_tcp should use --source rtltcp instead of rtlsdr."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
mock_pty.return_value = (10, 11)
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
# Use a real pipe — hardcoded fds like (10, 11) collide with fds
|
||||
# the test process actually has open (DB, log files, capture)
|
||||
mock_pty.return_value = os.pipe()
|
||||
mock_process = MagicMock()
|
||||
mock_process.poll.return_value = None
|
||||
mock_popen.return_value = mock_process
|
||||
@@ -148,10 +179,10 @@ class TestWeatherSatDecoder:
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success, error_msg = decoder.start(
|
||||
satellite='NOAA-18',
|
||||
satellite="NOAA-18",
|
||||
device_index=0,
|
||||
gain=40.0,
|
||||
rtl_tcp_host='192.168.1.100',
|
||||
rtl_tcp_host="192.168.1.100",
|
||||
rtl_tcp_port=1234,
|
||||
)
|
||||
|
||||
@@ -160,24 +191,28 @@ class TestWeatherSatDecoder:
|
||||
|
||||
mock_popen.assert_called_once()
|
||||
cmd = mock_popen.call_args[0][0]
|
||||
assert '--source' in cmd
|
||||
source_idx = cmd.index('--source')
|
||||
assert cmd[source_idx + 1] == 'rtltcp'
|
||||
assert '--ip_address' in cmd
|
||||
assert '192.168.1.100' in cmd
|
||||
assert '--port' in cmd
|
||||
assert '1234' in cmd
|
||||
assert "--source" in cmd
|
||||
source_idx = cmd.index("--source")
|
||||
assert cmd[source_idx + 1] == "rtltcp"
|
||||
assert "--ip_address" in cmd
|
||||
assert "192.168.1.100" in cmd
|
||||
assert "--port" in cmd
|
||||
assert "1234" in cmd
|
||||
# Should NOT have --source_id for remote
|
||||
assert '--source_id' not in cmd
|
||||
assert "--source_id" not in cmd
|
||||
|
||||
@patch('subprocess.Popen')
|
||||
@patch('pty.openpty')
|
||||
@patch('utils.weather_sat.register_process')
|
||||
@patch("subprocess.Popen")
|
||||
@patch("pty.openpty")
|
||||
@patch("utils.weather_sat.register_process")
|
||||
def test_start_rtl_tcp_skips_device_resolve(self, mock_register, mock_pty, mock_popen):
|
||||
"""start() with rtl_tcp should skip _resolve_device_id."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'), \
|
||||
patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id') as mock_resolve:
|
||||
mock_pty.return_value = (10, 11)
|
||||
with (
|
||||
patch("shutil.which", return_value="/usr/bin/satdump"),
|
||||
patch("utils.weather_sat.WeatherSatDecoder._resolve_device_id") as mock_resolve,
|
||||
):
|
||||
# Use a real pipe — hardcoded fds like (10, 11) collide with fds
|
||||
# the test process actually has open (DB, log files, capture)
|
||||
mock_pty.return_value = os.pipe()
|
||||
mock_process = MagicMock()
|
||||
mock_process.poll.return_value = None
|
||||
mock_popen.return_value = mock_process
|
||||
@@ -185,83 +220,87 @@ class TestWeatherSatDecoder:
|
||||
decoder = WeatherSatDecoder()
|
||||
|
||||
success, _ = decoder.start(
|
||||
satellite='NOAA-18',
|
||||
satellite="NOAA-18",
|
||||
device_index=0,
|
||||
gain=40.0,
|
||||
rtl_tcp_host='10.0.0.1',
|
||||
rtl_tcp_host="10.0.0.1",
|
||||
)
|
||||
|
||||
assert success is True
|
||||
mock_resolve.assert_not_called()
|
||||
|
||||
@patch('subprocess.Popen')
|
||||
@patch('pty.openpty')
|
||||
@patch("subprocess.Popen")
|
||||
@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'), \
|
||||
patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'):
|
||||
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, error_msg = 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')
|
||||
@patch('pty.openpty')
|
||||
@patch("subprocess.Popen")
|
||||
@patch("pty.openpty")
|
||||
def test_start_exception_handling(self, mock_pty, mock_popen):
|
||||
"""start() should handle exceptions gracefully."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
mock_pty.return_value = (10, 11)
|
||||
mock_popen.side_effect = OSError('Device not found')
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
# Use a real pipe — hardcoded fds like (10, 11) collide with fds
|
||||
# the test process actually has open (DB, log files, capture)
|
||||
mock_pty.return_value = os.pipe()
|
||||
mock_popen.side_effect = OSError("Device not found")
|
||||
|
||||
decoder = WeatherSatDecoder()
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success, error_msg = 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]
|
||||
assert progress.status == 'error'
|
||||
assert progress.status == "error"
|
||||
|
||||
def test_start_from_file_no_decoder(self):
|
||||
"""start_from_file() should fail when no decoder available."""
|
||||
with patch('shutil.which', return_value=None):
|
||||
with patch("shutil.which", return_value=None):
|
||||
decoder = WeatherSatDecoder()
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success, error_msg = decoder.start_from_file(
|
||||
satellite='NOAA-18',
|
||||
input_file='data/test.wav',
|
||||
satellite="NOAA-18",
|
||||
input_file="data/test.wav",
|
||||
)
|
||||
|
||||
assert success is False
|
||||
assert error_msg is not None
|
||||
callback.assert_called()
|
||||
|
||||
@patch('subprocess.Popen')
|
||||
@patch('pty.openpty')
|
||||
@patch('pathlib.Path.is_file', return_value=True)
|
||||
@patch('pathlib.Path.resolve')
|
||||
@patch("subprocess.Popen")
|
||||
@patch("pty.openpty")
|
||||
@patch("pathlib.Path.is_file", return_value=True)
|
||||
@patch("pathlib.Path.resolve")
|
||||
def test_start_from_file_success(self, mock_resolve, mock_is_file, mock_pty, mock_popen):
|
||||
"""start_from_file() should successfully decode from file."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'), \
|
||||
patch('utils.weather_sat.register_process'):
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"), patch("utils.weather_sat.register_process"):
|
||||
# Mock path resolution
|
||||
mock_path = MagicMock()
|
||||
mock_path.is_relative_to.return_value = True
|
||||
mock_path.suffix = '.wav'
|
||||
mock_path.suffix = ".wav"
|
||||
mock_resolve.return_value = mock_path
|
||||
|
||||
mock_pty.return_value = (10, 11)
|
||||
# Use a real pipe — hardcoded fds like (10, 11) collide with fds
|
||||
# the test process actually has open (DB, log files, capture)
|
||||
mock_pty.return_value = os.pipe()
|
||||
mock_process = MagicMock()
|
||||
mock_process.poll.return_value = None # Process still running
|
||||
mock_popen.return_value = mock_process
|
||||
@@ -271,27 +310,27 @@ class TestWeatherSatDecoder:
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success, error_msg = decoder.start_from_file(
|
||||
satellite='NOAA-18',
|
||||
input_file='data/test.wav',
|
||||
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'
|
||||
assert decoder.current_satellite == "NOAA-18"
|
||||
|
||||
mock_popen.assert_called_once()
|
||||
cmd = mock_popen.call_args[0][0]
|
||||
assert cmd[0] == 'satdump'
|
||||
assert 'noaa_apt' in cmd
|
||||
assert 'audio_wav' in cmd
|
||||
assert '--samplerate' in cmd
|
||||
assert cmd[0] == "satdump"
|
||||
assert "noaa_apt" in cmd
|
||||
assert "audio_wav" in cmd
|
||||
assert "--samplerate" in cmd
|
||||
|
||||
@patch('pathlib.Path.resolve')
|
||||
@patch("pathlib.Path.resolve")
|
||||
def test_start_from_file_path_traversal(self, mock_resolve):
|
||||
"""start_from_file() should block path traversal."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
# Mock path outside allowed directory
|
||||
mock_path = MagicMock()
|
||||
mock_path.is_relative_to.return_value = False
|
||||
@@ -302,20 +341,20 @@ class TestWeatherSatDecoder:
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success, error_msg = decoder.start_from_file(
|
||||
satellite='NOAA-18',
|
||||
input_file='/etc/passwd',
|
||||
satellite="NOAA-18",
|
||||
input_file="/etc/passwd",
|
||||
)
|
||||
|
||||
assert success is False
|
||||
callback.assert_called()
|
||||
progress = callback.call_args[0][0]
|
||||
assert 'data/ directory' in progress.message
|
||||
assert "must be under INTERCEPT data" in progress.message
|
||||
|
||||
@patch('pathlib.Path.is_file', return_value=False)
|
||||
@patch('pathlib.Path.resolve')
|
||||
@patch("pathlib.Path.is_file", return_value=False)
|
||||
@patch("pathlib.Path.resolve")
|
||||
def test_start_from_file_not_found(self, mock_resolve, mock_is_file):
|
||||
"""start_from_file() should fail when file not found."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
mock_path = MagicMock()
|
||||
mock_path.is_relative_to.return_value = True
|
||||
mock_resolve.return_value = mock_path
|
||||
@@ -325,32 +364,32 @@ class TestWeatherSatDecoder:
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success, error_msg = decoder.start_from_file(
|
||||
satellite='NOAA-18',
|
||||
input_file='data/missing.wav',
|
||||
satellite="NOAA-18",
|
||||
input_file="data/missing.wav",
|
||||
)
|
||||
|
||||
assert success is False
|
||||
callback.assert_called()
|
||||
progress = callback.call_args[0][0]
|
||||
assert 'not found' in progress.message.lower()
|
||||
assert "not found" in progress.message.lower()
|
||||
|
||||
def test_stop_not_running(self):
|
||||
"""stop() should be safe when not running."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
decoder.stop() # Should not raise
|
||||
|
||||
@patch('utils.weather_sat.safe_terminate')
|
||||
@patch("utils.weather_sat.safe_terminate")
|
||||
def test_stop_running(self, mock_terminate):
|
||||
"""stop() should terminate process."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
mock_process = MagicMock()
|
||||
decoder._process = mock_process
|
||||
decoder._running = True
|
||||
decoder._pty_master_fd = 10
|
||||
|
||||
with patch('os.close') as mock_close:
|
||||
with patch("os.close") as mock_close:
|
||||
decoder.stop()
|
||||
|
||||
assert decoder._running is False
|
||||
@@ -359,21 +398,21 @@ class TestWeatherSatDecoder:
|
||||
|
||||
def test_get_images_empty(self):
|
||||
"""get_images() should return empty list initially."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
images = decoder.get_images()
|
||||
assert images == []
|
||||
|
||||
@patch('pathlib.Path.glob')
|
||||
@patch('pathlib.Path.stat')
|
||||
@patch("pathlib.Path.glob")
|
||||
@patch("pathlib.Path.stat")
|
||||
def test_get_images_scans_directory(self, mock_stat, mock_glob):
|
||||
"""get_images() should scan output directory."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
|
||||
# Mock image files
|
||||
mock_file = MagicMock()
|
||||
mock_file.name = 'NOAA-18_test.png'
|
||||
mock_file.name = "NOAA-18_test.png"
|
||||
mock_file.stat.return_value.st_size = 10000
|
||||
mock_file.stat.return_value.st_mtime = time.time()
|
||||
mock_glob.return_value = [mock_file]
|
||||
@@ -381,39 +420,39 @@ class TestWeatherSatDecoder:
|
||||
images = decoder.get_images()
|
||||
|
||||
assert len(images) == 1
|
||||
assert images[0].filename == 'NOAA-18_test.png'
|
||||
assert images[0].satellite == 'NOAA-18'
|
||||
assert images[0].filename == "NOAA-18_test.png"
|
||||
assert images[0].satellite == "NOAA-18"
|
||||
|
||||
def test_delete_image_success(self):
|
||||
"""delete_image() should delete file."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
|
||||
with patch('pathlib.Path.exists', return_value=True), \
|
||||
patch('pathlib.Path.unlink') as mock_unlink:
|
||||
|
||||
result = decoder.delete_image('test.png')
|
||||
with patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.unlink") as mock_unlink:
|
||||
result = decoder.delete_image("test.png")
|
||||
|
||||
assert result is True
|
||||
mock_unlink.assert_called_once()
|
||||
|
||||
def test_delete_image_not_found(self):
|
||||
"""delete_image() should return False for non-existent file."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
|
||||
with patch('pathlib.Path.exists', return_value=False):
|
||||
result = decoder.delete_image('missing.png')
|
||||
with patch("pathlib.Path.exists", return_value=False):
|
||||
result = decoder.delete_image("missing.png")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_delete_all_images(self):
|
||||
"""delete_all_images() should delete all images."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
|
||||
mock_files = [MagicMock() for _ in range(3)]
|
||||
with patch('pathlib.Path.glob', return_value=mock_files):
|
||||
# delete_all_images globs three extensions; return files for the
|
||||
# first pattern only so each mock is deleted exactly once
|
||||
with patch("pathlib.Path.glob", side_effect=[mock_files, [], []]):
|
||||
count = decoder.delete_all_images()
|
||||
|
||||
assert count == 3
|
||||
@@ -422,74 +461,74 @@ class TestWeatherSatDecoder:
|
||||
|
||||
def test_get_status_idle(self):
|
||||
"""get_status() should return idle status."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
status = decoder.get_status()
|
||||
|
||||
assert status['available'] is True
|
||||
assert status['decoder'] == 'satdump'
|
||||
assert status['running'] is False
|
||||
assert status['satellite'] == ''
|
||||
assert status["available"] is True
|
||||
assert status["decoder"] == "satdump"
|
||||
assert status["running"] is False
|
||||
assert status["satellite"] == ""
|
||||
|
||||
def test_get_status_running(self):
|
||||
"""get_status() should return running status."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
decoder._running = True
|
||||
decoder._current_satellite = 'NOAA-18'
|
||||
decoder._current_satellite = "NOAA-18"
|
||||
decoder._current_frequency = 137.9125
|
||||
decoder._current_mode = 'APT'
|
||||
decoder._current_mode = "APT"
|
||||
decoder._capture_start_time = time.time() - 60
|
||||
|
||||
status = decoder.get_status()
|
||||
|
||||
assert status['running'] is True
|
||||
assert status['satellite'] == 'NOAA-18'
|
||||
assert status['frequency'] == 137.9125
|
||||
assert status['mode'] == 'APT'
|
||||
assert status['elapsed_seconds'] >= 60
|
||||
assert status["running"] is True
|
||||
assert status["satellite"] == "NOAA-18"
|
||||
assert status["frequency"] == 137.9125
|
||||
assert status["mode"] == "APT"
|
||||
assert status["elapsed_seconds"] >= 60
|
||||
|
||||
def test_classify_log_type_error(self):
|
||||
"""_classify_log_type() should detect errors."""
|
||||
assert WeatherSatDecoder._classify_log_type('(E) Error occurred') == 'error'
|
||||
assert WeatherSatDecoder._classify_log_type('Failed to open device') == 'error'
|
||||
assert WeatherSatDecoder._classify_log_type("(E) Error occurred") == "error"
|
||||
assert WeatherSatDecoder._classify_log_type("Failed to open device") == "error"
|
||||
|
||||
def test_classify_log_type_progress(self):
|
||||
"""_classify_log_type() should detect progress."""
|
||||
assert WeatherSatDecoder._classify_log_type('Progress: 50%') == 'progress'
|
||||
assert WeatherSatDecoder._classify_log_type("Progress: 50%") == "progress"
|
||||
|
||||
def test_classify_log_type_save(self):
|
||||
"""_classify_log_type() should detect save events."""
|
||||
assert WeatherSatDecoder._classify_log_type('Saved image: test.png') == 'save'
|
||||
assert WeatherSatDecoder._classify_log_type('Writing output file') == 'save'
|
||||
assert WeatherSatDecoder._classify_log_type("Saved image: test.png") == "save"
|
||||
assert WeatherSatDecoder._classify_log_type("Writing output file") == "save"
|
||||
|
||||
def test_classify_log_type_signal(self):
|
||||
"""_classify_log_type() should detect signal events."""
|
||||
assert WeatherSatDecoder._classify_log_type('Signal detected') == 'signal'
|
||||
assert WeatherSatDecoder._classify_log_type('Lock acquired') == 'signal'
|
||||
assert WeatherSatDecoder._classify_log_type("Signal detected") == "signal"
|
||||
assert WeatherSatDecoder._classify_log_type("Lock acquired") == "signal"
|
||||
|
||||
def test_classify_log_type_warning(self):
|
||||
"""_classify_log_type() should detect warnings."""
|
||||
assert WeatherSatDecoder._classify_log_type('(W) Low signal quality') == 'warning'
|
||||
assert WeatherSatDecoder._classify_log_type("(W) Low signal quality") == "warning"
|
||||
|
||||
def test_classify_log_type_debug(self):
|
||||
"""_classify_log_type() should detect debug messages."""
|
||||
assert WeatherSatDecoder._classify_log_type('(D) Debug info') == 'debug'
|
||||
assert WeatherSatDecoder._classify_log_type("(D) Debug info") == "debug"
|
||||
|
||||
@patch('subprocess.run')
|
||||
@patch("subprocess.run")
|
||||
def test_resolve_device_id_success(self, mock_run):
|
||||
"""_resolve_device_id() should extract serial from rtl_test."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = 'Found 1 device(s):\n 0: RTLSDRBlog, SN: 00004000'
|
||||
mock_result.stderr = ''
|
||||
mock_result.stdout = "Found 1 device(s):\n 0: RTLSDRBlog, SN: 00004000"
|
||||
mock_result.stderr = ""
|
||||
mock_run.return_value = mock_result
|
||||
|
||||
serial = WeatherSatDecoder._resolve_device_id(0)
|
||||
|
||||
assert serial == '00004000'
|
||||
assert serial == "00004000"
|
||||
mock_run.assert_called_once()
|
||||
|
||||
@patch('subprocess.run')
|
||||
@patch("subprocess.run")
|
||||
def test_resolve_device_id_fallback(self, mock_run):
|
||||
"""_resolve_device_id() should return None when no serial found."""
|
||||
mock_run.side_effect = FileNotFoundError
|
||||
@@ -500,59 +539,59 @@ class TestWeatherSatDecoder:
|
||||
|
||||
def test_parse_product_name_rgb(self):
|
||||
"""_parse_product_name() should identify RGB composite."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
product = decoder._parse_product_name(Path('/tmp/output/rgb_composite.png'))
|
||||
assert product == 'RGB Composite'
|
||||
product = decoder._parse_product_name(Path("/tmp/output/rgb_composite.png"))
|
||||
assert product == "RGB Composite"
|
||||
|
||||
def test_parse_product_name_thermal(self):
|
||||
"""_parse_product_name() should identify thermal imagery."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
product = decoder._parse_product_name(Path('/tmp/output/thermal_image.png'))
|
||||
assert product == 'Thermal'
|
||||
product = decoder._parse_product_name(Path("/tmp/output/thermal_image.png"))
|
||||
assert product == "Thermal"
|
||||
|
||||
def test_parse_product_name_channel(self):
|
||||
"""_parse_product_name() should identify channel images."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
product = decoder._parse_product_name(Path('/tmp/output/channel_3.png'))
|
||||
assert product == 'Channel 3'
|
||||
product = decoder._parse_product_name(Path("/tmp/output/channel_3.png"))
|
||||
assert product == "Channel 3"
|
||||
|
||||
def test_parse_product_name_unknown(self):
|
||||
"""_parse_product_name() should return stem for unknown products."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
product = decoder._parse_product_name(Path('/tmp/output/unknown_image.png'))
|
||||
assert product == 'unknown_image'
|
||||
product = decoder._parse_product_name(Path("/tmp/output/unknown_image.png"))
|
||||
assert product == "unknown_image"
|
||||
|
||||
def test_emit_progress(self):
|
||||
"""_emit_progress() should call callback."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
progress = CaptureProgress(status='capturing', message='Test')
|
||||
progress = CaptureProgress(status="capturing", message="Test")
|
||||
decoder._emit_progress(progress)
|
||||
|
||||
callback.assert_called_once_with(progress)
|
||||
|
||||
def test_emit_progress_no_callback(self):
|
||||
"""_emit_progress() should handle missing callback."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
progress = CaptureProgress(status='capturing', message='Test')
|
||||
progress = CaptureProgress(status="capturing", message="Test")
|
||||
decoder._emit_progress(progress) # Should not raise
|
||||
|
||||
def test_emit_progress_callback_exception(self):
|
||||
"""_emit_progress() should handle callback exceptions."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
decoder = WeatherSatDecoder()
|
||||
callback = MagicMock(side_effect=Exception('Callback error'))
|
||||
callback = MagicMock(side_effect=Exception("Callback error"))
|
||||
decoder.set_callback(callback)
|
||||
|
||||
progress = CaptureProgress(status='capturing', message='Test')
|
||||
progress = CaptureProgress(status="capturing", message="Test")
|
||||
decoder._emit_progress(progress) # Should not raise
|
||||
|
||||
|
||||
@@ -562,26 +601,26 @@ class TestWeatherSatImage:
|
||||
def test_to_dict(self):
|
||||
"""WeatherSatImage.to_dict() should serialize correctly."""
|
||||
image = WeatherSatImage(
|
||||
filename='test.png',
|
||||
path=Path('/tmp/test.png'),
|
||||
satellite='NOAA-18',
|
||||
mode='APT',
|
||||
filename="test.png",
|
||||
path=Path("/tmp/test.png"),
|
||||
satellite="NOAA-18",
|
||||
mode="APT",
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
frequency=137.9125,
|
||||
size_bytes=12345,
|
||||
product='RGB Composite',
|
||||
product="RGB Composite",
|
||||
)
|
||||
|
||||
data = image.to_dict()
|
||||
|
||||
assert data['filename'] == 'test.png'
|
||||
assert data['satellite'] == 'NOAA-18'
|
||||
assert data['mode'] == 'APT'
|
||||
assert data['timestamp'] == '2024-01-01T12:00:00+00:00'
|
||||
assert data['frequency'] == 137.9125
|
||||
assert data['size_bytes'] == 12345
|
||||
assert data['product'] == 'RGB Composite'
|
||||
assert data['url'] == '/weather-sat/images/test.png'
|
||||
assert data["filename"] == "test.png"
|
||||
assert data["satellite"] == "NOAA-18"
|
||||
assert data["mode"] == "APT"
|
||||
assert data["timestamp"] == "2024-01-01T12:00:00+00:00"
|
||||
assert data["frequency"] == 137.9125
|
||||
assert data["size_bytes"] == 12345
|
||||
assert data["product"] == "RGB Composite"
|
||||
assert data["url"] == "/weather-sat/images/test.png"
|
||||
|
||||
|
||||
class TestCaptureProgress:
|
||||
@@ -589,51 +628,51 @@ class TestCaptureProgress:
|
||||
|
||||
def test_to_dict_minimal(self):
|
||||
"""CaptureProgress.to_dict() with minimal fields."""
|
||||
progress = CaptureProgress(status='idle')
|
||||
progress = CaptureProgress(status="idle")
|
||||
data = progress.to_dict()
|
||||
|
||||
assert data['type'] == 'weather_sat_progress'
|
||||
assert data['status'] == 'idle'
|
||||
assert data['satellite'] == ''
|
||||
assert data['message'] == ''
|
||||
assert data['progress'] == 0
|
||||
assert data["type"] == "weather_sat_progress"
|
||||
assert data["status"] == "idle"
|
||||
assert data["satellite"] == ""
|
||||
assert data["message"] == ""
|
||||
assert data["progress"] == 0
|
||||
|
||||
def test_to_dict_complete(self):
|
||||
"""CaptureProgress.to_dict() with all fields."""
|
||||
image = WeatherSatImage(
|
||||
filename='test.png',
|
||||
path=Path('/tmp/test.png'),
|
||||
satellite='NOAA-18',
|
||||
mode='APT',
|
||||
filename="test.png",
|
||||
path=Path("/tmp/test.png"),
|
||||
satellite="NOAA-18",
|
||||
mode="APT",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
frequency=137.9125,
|
||||
)
|
||||
|
||||
progress = CaptureProgress(
|
||||
status='complete',
|
||||
satellite='NOAA-18',
|
||||
status="complete",
|
||||
satellite="NOAA-18",
|
||||
frequency=137.9125,
|
||||
mode='APT',
|
||||
message='Capture complete',
|
||||
mode="APT",
|
||||
message="Capture complete",
|
||||
progress_percent=100,
|
||||
elapsed_seconds=600,
|
||||
image=image,
|
||||
log_type='info',
|
||||
capture_phase='complete',
|
||||
log_type="info",
|
||||
capture_phase="complete",
|
||||
)
|
||||
|
||||
data = progress.to_dict()
|
||||
|
||||
assert data['status'] == 'complete'
|
||||
assert data['satellite'] == 'NOAA-18'
|
||||
assert data['frequency'] == 137.9125
|
||||
assert data['mode'] == 'APT'
|
||||
assert data['message'] == 'Capture complete'
|
||||
assert data['progress'] == 100
|
||||
assert data['elapsed_seconds'] == 600
|
||||
assert 'image' in data
|
||||
assert data['log_type'] == 'info'
|
||||
assert data['capture_phase'] == 'complete'
|
||||
assert data["status"] == "complete"
|
||||
assert data["satellite"] == "NOAA-18"
|
||||
assert data["frequency"] == 137.9125
|
||||
assert data["mode"] == "APT"
|
||||
assert data["message"] == "Capture complete"
|
||||
assert data["progress"] == 100
|
||||
assert data["elapsed_seconds"] == 600
|
||||
assert "image" in data
|
||||
assert data["log_type"] == "info"
|
||||
assert data["capture_phase"] == "complete"
|
||||
|
||||
|
||||
class TestGlobalFunctions:
|
||||
@@ -641,8 +680,9 @@ class TestGlobalFunctions:
|
||||
|
||||
def test_get_weather_sat_decoder_singleton(self):
|
||||
"""get_weather_sat_decoder() should return singleton."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
import utils.weather_sat as mod
|
||||
|
||||
old = mod._decoder
|
||||
mod._decoder = None
|
||||
|
||||
@@ -656,8 +696,9 @@ class TestGlobalFunctions:
|
||||
|
||||
def test_is_weather_sat_available_true(self):
|
||||
"""is_weather_sat_available() should return True when available."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||
import utils.weather_sat as mod
|
||||
|
||||
old = mod._decoder
|
||||
mod._decoder = None
|
||||
|
||||
@@ -668,8 +709,9 @@ class TestGlobalFunctions:
|
||||
|
||||
def test_is_weather_sat_available_false(self):
|
||||
"""is_weather_sat_available() should return False when unavailable."""
|
||||
with patch('shutil.which', return_value=None):
|
||||
with patch("shutil.which", return_value=None):
|
||||
import utils.weather_sat as mod
|
||||
|
||||
old = mod._decoder
|
||||
mod._decoder = None
|
||||
|
||||
@@ -684,26 +726,26 @@ class TestWeatherSatellitesConstant:
|
||||
|
||||
def test_weather_satellites_structure(self):
|
||||
"""WEATHER_SATELLITES should have correct structure."""
|
||||
assert 'NOAA-18' in WEATHER_SATELLITES
|
||||
sat = WEATHER_SATELLITES['NOAA-18']
|
||||
assert "NOAA-18" in WEATHER_SATELLITES
|
||||
sat = WEATHER_SATELLITES["NOAA-18"]
|
||||
|
||||
assert 'name' in sat
|
||||
assert 'frequency' in sat
|
||||
assert 'mode' in sat
|
||||
assert 'pipeline' in sat
|
||||
assert 'tle_key' in sat
|
||||
assert 'description' in sat
|
||||
assert 'active' in sat
|
||||
assert "name" in sat
|
||||
assert "frequency" in sat
|
||||
assert "mode" in sat
|
||||
assert "pipeline" in sat
|
||||
assert "tle_key" in sat
|
||||
assert "description" in sat
|
||||
assert "active" in sat
|
||||
|
||||
def test_noaa_satellites(self):
|
||||
"""NOAA satellites should have correct frequencies."""
|
||||
assert WEATHER_SATELLITES['NOAA-15']['frequency'] == 137.620
|
||||
assert WEATHER_SATELLITES['NOAA-18']['frequency'] == 137.9125
|
||||
assert WEATHER_SATELLITES['NOAA-19']['frequency'] == 137.100
|
||||
assert WEATHER_SATELLITES["NOAA-15"]["frequency"] == 137.620
|
||||
assert WEATHER_SATELLITES["NOAA-18"]["frequency"] == 137.9125
|
||||
assert WEATHER_SATELLITES["NOAA-19"]["frequency"] == 137.100
|
||||
|
||||
def test_meteor_satellite(self):
|
||||
"""Meteor satellite should use LRPT mode."""
|
||||
meteor = WEATHER_SATELLITES['METEOR-M2-3']
|
||||
assert meteor['mode'] == 'LRPT'
|
||||
assert meteor['frequency'] == 137.900
|
||||
assert meteor['pipeline'] == 'meteor_m2-x_lrpt'
|
||||
meteor = WEATHER_SATELLITES["METEOR-M2-3"]
|
||||
assert meteor["mode"] == "LRPT"
|
||||
assert meteor["frequency"] == 137.900
|
||||
assert meteor["pipeline"] == "meteor_m2-x_lrpt"
|
||||
|
||||
+169
-179
@@ -17,13 +17,13 @@ from utils.weather_sat_predict import _format_utc_iso, predict_passes
|
||||
# NOAA-18 was decommissioned Jun 2025 and is inactive in the real WEATHER_SATELLITES,
|
||||
# so tests that assert on satellite-specific fields patch the module-level name.
|
||||
_MOCK_WEATHER_SATS = {
|
||||
'NOAA-18': {
|
||||
'name': 'NOAA 18',
|
||||
'frequency': 137.9125,
|
||||
'mode': 'APT',
|
||||
'pipeline': 'noaa_apt',
|
||||
'tle_key': 'NOAA-18',
|
||||
'active': True,
|
||||
"NOAA-18": {
|
||||
"name": "NOAA 18",
|
||||
"frequency": 137.9125,
|
||||
"mode": "APT",
|
||||
"pipeline": "noaa_apt",
|
||||
"tle_key": "NOAA-18",
|
||||
"active": True,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ _MOCK_WEATHER_SATS = {
|
||||
class TestPredictPasses:
|
||||
"""Tests for predict_passes() function."""
|
||||
|
||||
@patch('utils.weather_sat_predict.load')
|
||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||
@patch("utils.weather_sat_predict.load")
|
||||
@patch("utils.weather_sat_predict.TLE_SATELLITES")
|
||||
def test_predict_passes_no_tle_data(self, mock_tle, mock_load):
|
||||
"""predict_passes() should handle missing TLE data."""
|
||||
mock_tle.get.return_value = None
|
||||
@@ -45,12 +45,12 @@ class TestPredictPasses:
|
||||
|
||||
assert passes == []
|
||||
|
||||
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||
@patch('utils.weather_sat_predict.load')
|
||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||
@patch('utils.weather_sat_predict.wgs84')
|
||||
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||
@patch('utils.weather_sat_predict.find_discrete')
|
||||
@patch("utils.weather_sat_predict.WEATHER_SATELLITES", _MOCK_WEATHER_SATS)
|
||||
@patch("utils.weather_sat_predict.load")
|
||||
@patch("utils.weather_sat_predict.TLE_SATELLITES")
|
||||
@patch("utils.weather_sat_predict.wgs84")
|
||||
@patch("utils.weather_sat_predict.EarthSatellite")
|
||||
@patch("utils.weather_sat_predict.find_discrete")
|
||||
def test_predict_passes_basic(self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load):
|
||||
"""predict_passes() should predict basic passes."""
|
||||
# Mock timescale
|
||||
@@ -64,9 +64,9 @@ class TestPredictPasses:
|
||||
|
||||
# Mock TLE data
|
||||
mock_tle.get.return_value = (
|
||||
'NOAA-18',
|
||||
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||
"NOAA-18",
|
||||
"1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999",
|
||||
"2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000",
|
||||
)
|
||||
|
||||
# Mock observer
|
||||
@@ -103,23 +103,21 @@ class TestPredictPasses:
|
||||
|
||||
assert len(passes) == 1
|
||||
pass_data = passes[0]
|
||||
assert pass_data['satellite'] == 'NOAA-18'
|
||||
assert pass_data['name'] == 'NOAA 18'
|
||||
assert pass_data['frequency'] == 137.9125
|
||||
assert pass_data['mode'] == 'APT'
|
||||
assert 'maxEl' in pass_data
|
||||
assert 'duration' in pass_data
|
||||
assert 'quality' in pass_data
|
||||
assert pass_data["satellite"] == "NOAA-18"
|
||||
assert pass_data["name"] == "NOAA 18"
|
||||
assert pass_data["frequency"] == 137.9125
|
||||
assert pass_data["mode"] == "APT"
|
||||
assert "maxEl" in pass_data
|
||||
assert "duration" in pass_data
|
||||
assert "quality" in pass_data
|
||||
|
||||
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||
@patch('utils.weather_sat_predict.load')
|
||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||
@patch('utils.weather_sat_predict.wgs84')
|
||||
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||
@patch('utils.weather_sat_predict.find_discrete')
|
||||
def test_predict_passes_below_min_elevation(
|
||||
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||
):
|
||||
@patch("utils.weather_sat_predict.WEATHER_SATELLITES", _MOCK_WEATHER_SATS)
|
||||
@patch("utils.weather_sat_predict.load")
|
||||
@patch("utils.weather_sat_predict.TLE_SATELLITES")
|
||||
@patch("utils.weather_sat_predict.wgs84")
|
||||
@patch("utils.weather_sat_predict.EarthSatellite")
|
||||
@patch("utils.weather_sat_predict.find_discrete")
|
||||
def test_predict_passes_below_min_elevation(self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load):
|
||||
"""predict_passes() should filter passes below min elevation."""
|
||||
mock_ts = MagicMock()
|
||||
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
@@ -130,9 +128,9 @@ class TestPredictPasses:
|
||||
mock_load.timescale.return_value = mock_ts
|
||||
|
||||
mock_tle.get.return_value = (
|
||||
'NOAA-18',
|
||||
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||
"NOAA-18",
|
||||
"1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999",
|
||||
"2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000",
|
||||
)
|
||||
|
||||
mock_observer = MagicMock()
|
||||
@@ -166,15 +164,13 @@ class TestPredictPasses:
|
||||
|
||||
assert len(passes) == 0
|
||||
|
||||
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||
@patch('utils.weather_sat_predict.load')
|
||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||
@patch('utils.weather_sat_predict.wgs84')
|
||||
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||
@patch('utils.weather_sat_predict.find_discrete')
|
||||
def test_predict_passes_with_trajectory(
|
||||
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||
):
|
||||
@patch("utils.weather_sat_predict.WEATHER_SATELLITES", _MOCK_WEATHER_SATS)
|
||||
@patch("utils.weather_sat_predict.load")
|
||||
@patch("utils.weather_sat_predict.TLE_SATELLITES")
|
||||
@patch("utils.weather_sat_predict.wgs84")
|
||||
@patch("utils.weather_sat_predict.EarthSatellite")
|
||||
@patch("utils.weather_sat_predict.find_discrete")
|
||||
def test_predict_passes_with_trajectory(self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load):
|
||||
"""predict_passes() should include trajectory when requested."""
|
||||
mock_ts = MagicMock()
|
||||
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
@@ -185,9 +181,9 @@ class TestPredictPasses:
|
||||
mock_load.timescale.return_value = mock_ts
|
||||
|
||||
mock_tle.get.return_value = (
|
||||
'NOAA-18',
|
||||
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||
"NOAA-18",
|
||||
"1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999",
|
||||
"2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000",
|
||||
)
|
||||
|
||||
mock_observer = MagicMock()
|
||||
@@ -216,23 +212,19 @@ class TestPredictPasses:
|
||||
mock_diff.at.side_effect = mock_topocentric
|
||||
mock_satellite_obj.__sub__.return_value = mock_diff
|
||||
|
||||
passes = predict_passes(
|
||||
lat=51.5, lon=-0.1, hours=24, min_elevation=15, include_trajectory=True
|
||||
)
|
||||
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15, include_trajectory=True)
|
||||
|
||||
assert len(passes) == 1
|
||||
assert 'trajectory' in passes[0]
|
||||
assert len(passes[0]['trajectory']) == 30
|
||||
assert "trajectory" in passes[0]
|
||||
assert len(passes[0]["trajectory"]) == 30
|
||||
|
||||
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||
@patch('utils.weather_sat_predict.load')
|
||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||
@patch('utils.weather_sat_predict.wgs84')
|
||||
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||
@patch('utils.weather_sat_predict.find_discrete')
|
||||
def test_predict_passes_with_ground_track(
|
||||
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||
):
|
||||
@patch("utils.weather_sat_predict.WEATHER_SATELLITES", _MOCK_WEATHER_SATS)
|
||||
@patch("utils.weather_sat_predict.load")
|
||||
@patch("utils.weather_sat_predict.TLE_SATELLITES")
|
||||
@patch("utils.weather_sat_predict.wgs84")
|
||||
@patch("utils.weather_sat_predict.EarthSatellite")
|
||||
@patch("utils.weather_sat_predict.find_discrete")
|
||||
def test_predict_passes_with_ground_track(self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load):
|
||||
"""predict_passes() should include ground track when requested."""
|
||||
mock_ts = MagicMock()
|
||||
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
@@ -243,9 +235,9 @@ class TestPredictPasses:
|
||||
mock_load.timescale.return_value = mock_ts
|
||||
|
||||
mock_tle.get.return_value = (
|
||||
'NOAA-18',
|
||||
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||
"NOAA-18",
|
||||
"1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999",
|
||||
"2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000",
|
||||
)
|
||||
|
||||
mock_observer = MagicMock()
|
||||
@@ -291,23 +283,19 @@ class TestPredictPasses:
|
||||
mock_subpoint.longitude = mock_lon
|
||||
mock_wgs84.subpoint.return_value = mock_subpoint
|
||||
|
||||
passes = predict_passes(
|
||||
lat=51.5, lon=-0.1, hours=24, min_elevation=15, include_ground_track=True
|
||||
)
|
||||
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15, include_ground_track=True)
|
||||
|
||||
assert len(passes) == 1
|
||||
assert 'groundTrack' in passes[0]
|
||||
assert len(passes[0]['groundTrack']) == 60
|
||||
assert "groundTrack" in passes[0]
|
||||
assert len(passes[0]["groundTrack"]) == 60
|
||||
|
||||
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||
@patch('utils.weather_sat_predict.load')
|
||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||
@patch('utils.weather_sat_predict.wgs84')
|
||||
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||
@patch('utils.weather_sat_predict.find_discrete')
|
||||
def test_predict_passes_quality_excellent(
|
||||
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||
):
|
||||
@patch("utils.weather_sat_predict.WEATHER_SATELLITES", _MOCK_WEATHER_SATS)
|
||||
@patch("utils.weather_sat_predict.load")
|
||||
@patch("utils.weather_sat_predict.TLE_SATELLITES")
|
||||
@patch("utils.weather_sat_predict.wgs84")
|
||||
@patch("utils.weather_sat_predict.EarthSatellite")
|
||||
@patch("utils.weather_sat_predict.find_discrete")
|
||||
def test_predict_passes_quality_excellent(self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load):
|
||||
"""predict_passes() should mark high elevation passes as excellent."""
|
||||
mock_ts = MagicMock()
|
||||
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
@@ -318,9 +306,9 @@ class TestPredictPasses:
|
||||
mock_load.timescale.return_value = mock_ts
|
||||
|
||||
mock_tle.get.return_value = (
|
||||
'NOAA-18',
|
||||
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||
"NOAA-18",
|
||||
"1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999",
|
||||
"2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000",
|
||||
)
|
||||
|
||||
mock_observer = MagicMock()
|
||||
@@ -352,18 +340,16 @@ class TestPredictPasses:
|
||||
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||
|
||||
assert len(passes) == 1
|
||||
assert passes[0]['quality'] == 'excellent'
|
||||
assert passes[0]['maxEl'] >= 60
|
||||
assert passes[0]["quality"] == "excellent"
|
||||
assert passes[0]["maxEl"] >= 60
|
||||
|
||||
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||
@patch('utils.weather_sat_predict.load')
|
||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||
@patch('utils.weather_sat_predict.wgs84')
|
||||
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||
@patch('utils.weather_sat_predict.find_discrete')
|
||||
def test_predict_passes_quality_good(
|
||||
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||
):
|
||||
@patch("utils.weather_sat_predict.WEATHER_SATELLITES", _MOCK_WEATHER_SATS)
|
||||
@patch("utils.weather_sat_predict.load")
|
||||
@patch("utils.weather_sat_predict.TLE_SATELLITES")
|
||||
@patch("utils.weather_sat_predict.wgs84")
|
||||
@patch("utils.weather_sat_predict.EarthSatellite")
|
||||
@patch("utils.weather_sat_predict.find_discrete")
|
||||
def test_predict_passes_quality_good(self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load):
|
||||
"""predict_passes() should mark medium elevation passes as good."""
|
||||
mock_ts = MagicMock()
|
||||
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
@@ -374,9 +360,9 @@ class TestPredictPasses:
|
||||
mock_load.timescale.return_value = mock_ts
|
||||
|
||||
mock_tle.get.return_value = (
|
||||
'NOAA-18',
|
||||
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||
"NOAA-18",
|
||||
"1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999",
|
||||
"2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000",
|
||||
)
|
||||
|
||||
mock_observer = MagicMock()
|
||||
@@ -408,18 +394,16 @@ class TestPredictPasses:
|
||||
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||
|
||||
assert len(passes) == 1
|
||||
assert passes[0]['quality'] == 'good'
|
||||
assert 30 <= passes[0]['maxEl'] < 60
|
||||
assert passes[0]["quality"] == "good"
|
||||
assert 30 <= passes[0]["maxEl"] < 60
|
||||
|
||||
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||
@patch('utils.weather_sat_predict.load')
|
||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||
@patch('utils.weather_sat_predict.wgs84')
|
||||
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||
@patch('utils.weather_sat_predict.find_discrete')
|
||||
def test_predict_passes_quality_fair(
|
||||
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||
):
|
||||
@patch("utils.weather_sat_predict.WEATHER_SATELLITES", _MOCK_WEATHER_SATS)
|
||||
@patch("utils.weather_sat_predict.load")
|
||||
@patch("utils.weather_sat_predict.TLE_SATELLITES")
|
||||
@patch("utils.weather_sat_predict.wgs84")
|
||||
@patch("utils.weather_sat_predict.EarthSatellite")
|
||||
@patch("utils.weather_sat_predict.find_discrete")
|
||||
def test_predict_passes_quality_fair(self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load):
|
||||
"""predict_passes() should mark low elevation passes as fair."""
|
||||
mock_ts = MagicMock()
|
||||
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
@@ -430,9 +414,9 @@ class TestPredictPasses:
|
||||
mock_load.timescale.return_value = mock_ts
|
||||
|
||||
mock_tle.get.return_value = (
|
||||
'NOAA-18',
|
||||
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||
"NOAA-18",
|
||||
"1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999",
|
||||
"2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000",
|
||||
)
|
||||
|
||||
mock_observer = MagicMock()
|
||||
@@ -464,17 +448,15 @@ class TestPredictPasses:
|
||||
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||
|
||||
assert len(passes) == 1
|
||||
assert passes[0]['quality'] == 'fair'
|
||||
assert passes[0]['maxEl'] < 30
|
||||
assert passes[0]["quality"] == "fair"
|
||||
assert passes[0]["maxEl"] < 30
|
||||
|
||||
@patch('utils.weather_sat_predict.load')
|
||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||
@patch('utils.weather_sat_predict.wgs84')
|
||||
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||
@patch('utils.weather_sat_predict.find_discrete')
|
||||
def test_predict_passes_inactive_satellite(
|
||||
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||
):
|
||||
@patch("utils.weather_sat_predict.load")
|
||||
@patch("utils.weather_sat_predict.TLE_SATELLITES")
|
||||
@patch("utils.weather_sat_predict.wgs84")
|
||||
@patch("utils.weather_sat_predict.EarthSatellite")
|
||||
@patch("utils.weather_sat_predict.find_discrete")
|
||||
def test_predict_passes_inactive_satellite(self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load):
|
||||
"""predict_passes() should skip inactive satellites."""
|
||||
mock_ts = MagicMock()
|
||||
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
@@ -485,25 +467,24 @@ class TestPredictPasses:
|
||||
|
||||
# Temporarily mark satellite as inactive
|
||||
from utils.weather_sat import WEATHER_SATELLITES
|
||||
original_active = WEATHER_SATELLITES['NOAA-18']['active']
|
||||
WEATHER_SATELLITES['NOAA-18']['active'] = False
|
||||
|
||||
original_active = WEATHER_SATELLITES["NOAA-18"]["active"]
|
||||
WEATHER_SATELLITES["NOAA-18"]["active"] = False
|
||||
|
||||
try:
|
||||
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||
# Should not include NOAA-18
|
||||
noaa_18_passes = [p for p in passes if p['satellite'] == 'NOAA-18']
|
||||
noaa_18_passes = [p for p in passes if p["satellite"] == "NOAA-18"]
|
||||
assert len(noaa_18_passes) == 0
|
||||
finally:
|
||||
WEATHER_SATELLITES['NOAA-18']['active'] = original_active
|
||||
WEATHER_SATELLITES["NOAA-18"]["active"] = original_active
|
||||
|
||||
@patch('utils.weather_sat_predict.load')
|
||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||
@patch('utils.weather_sat_predict.wgs84')
|
||||
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||
@patch('utils.weather_sat_predict.find_discrete')
|
||||
def test_predict_passes_exception_handling(
|
||||
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||
):
|
||||
@patch("utils.weather_sat_predict.load")
|
||||
@patch("utils.weather_sat_predict.TLE_SATELLITES")
|
||||
@patch("utils.weather_sat_predict.wgs84")
|
||||
@patch("utils.weather_sat_predict.EarthSatellite")
|
||||
@patch("utils.weather_sat_predict.find_discrete")
|
||||
def test_predict_passes_exception_handling(self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load):
|
||||
"""predict_passes() should handle exceptions gracefully."""
|
||||
mock_ts = MagicMock()
|
||||
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
@@ -514,9 +495,9 @@ class TestPredictPasses:
|
||||
mock_load.timescale.return_value = mock_ts
|
||||
|
||||
mock_tle.get.return_value = (
|
||||
'NOAA-18',
|
||||
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||
"NOAA-18",
|
||||
"1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999",
|
||||
"2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000",
|
||||
)
|
||||
|
||||
mock_observer = MagicMock()
|
||||
@@ -526,40 +507,41 @@ class TestPredictPasses:
|
||||
mock_sat.return_value = mock_satellite_obj
|
||||
|
||||
# Make find_discrete raise exception
|
||||
mock_find.side_effect = Exception('Computation error')
|
||||
mock_find.side_effect = Exception("Computation error")
|
||||
|
||||
# Should not raise, just skip this satellite
|
||||
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||
# May include passes from other satellites or be empty
|
||||
assert isinstance(passes, list)
|
||||
|
||||
@patch('utils.weather_sat_predict.load')
|
||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||
@patch("utils.weather_sat_predict.load")
|
||||
@patch("utils.weather_sat_predict.TLE_SATELLITES")
|
||||
def test_predict_passes_uses_tle_cache(self, mock_tle, mock_load):
|
||||
"""predict_passes() should use live TLE cache if available."""
|
||||
with patch('utils.weather_sat_predict._tle_cache', {'NOAA-18': ('NOAA-18', 'line1', 'line2')}):
|
||||
"""predict_passes() should use live TLE store if available."""
|
||||
with patch(
|
||||
"utils.weather_sat_predict._get_tle_source", return_value={"NOAA-18": ("NOAA-18", "line1", "line2")}
|
||||
):
|
||||
mock_ts = MagicMock()
|
||||
mock_ts.now.return_value = MagicMock()
|
||||
mock_ts.utc.return_value = MagicMock()
|
||||
mock_load.timescale.return_value = mock_ts
|
||||
|
||||
# Even though TLE_SATELLITES is mocked, should use _tle_cache
|
||||
with patch('utils.weather_sat_predict.wgs84'), \
|
||||
patch('utils.weather_sat_predict.EarthSatellite'), \
|
||||
patch('utils.weather_sat_predict.find_discrete', return_value=([], [])):
|
||||
|
||||
# Even though TLE_SATELLITES is mocked, should use the unified store
|
||||
with (
|
||||
patch("utils.weather_sat_predict.wgs84"),
|
||||
patch("utils.weather_sat_predict.EarthSatellite"),
|
||||
patch("utils.weather_sat_predict.find_discrete", return_value=([], [])),
|
||||
):
|
||||
predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||
# Should not raise
|
||||
|
||||
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||
@patch('utils.weather_sat_predict.load')
|
||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||
@patch('utils.weather_sat_predict.wgs84')
|
||||
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||
@patch('utils.weather_sat_predict.find_discrete')
|
||||
def test_predict_passes_sorted_by_time(
|
||||
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||
):
|
||||
@patch("utils.weather_sat_predict.WEATHER_SATELLITES", _MOCK_WEATHER_SATS)
|
||||
@patch("utils.weather_sat_predict.load")
|
||||
@patch("utils.weather_sat_predict.TLE_SATELLITES")
|
||||
@patch("utils.weather_sat_predict.wgs84")
|
||||
@patch("utils.weather_sat_predict.EarthSatellite")
|
||||
@patch("utils.weather_sat_predict.find_discrete")
|
||||
def test_predict_passes_sorted_by_time(self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load):
|
||||
"""predict_passes() should return passes sorted by start time."""
|
||||
mock_ts = MagicMock()
|
||||
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
@@ -570,9 +552,9 @@ class TestPredictPasses:
|
||||
mock_load.timescale.return_value = mock_ts
|
||||
|
||||
mock_tle.get.return_value = (
|
||||
'NOAA-18',
|
||||
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||
"NOAA-18",
|
||||
"1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999",
|
||||
"2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000",
|
||||
)
|
||||
|
||||
mock_observer = MagicMock()
|
||||
@@ -611,7 +593,7 @@ class TestPredictPasses:
|
||||
|
||||
# Should be sorted with earliest pass first
|
||||
if len(passes) >= 2:
|
||||
assert passes[0]['startTimeISO'] < passes[1]['startTimeISO']
|
||||
assert passes[0]["startTimeISO"] < passes[1]["startTimeISO"]
|
||||
|
||||
@staticmethod
|
||||
def _mock_time(dt):
|
||||
@@ -627,15 +609,13 @@ class TestPredictPasses:
|
||||
class TestPassDataStructure:
|
||||
"""Tests for pass data structure."""
|
||||
|
||||
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||
@patch('utils.weather_sat_predict.load')
|
||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||
@patch('utils.weather_sat_predict.wgs84')
|
||||
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||
@patch('utils.weather_sat_predict.find_discrete')
|
||||
def test_pass_data_fields(
|
||||
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||
):
|
||||
@patch("utils.weather_sat_predict.WEATHER_SATELLITES", _MOCK_WEATHER_SATS)
|
||||
@patch("utils.weather_sat_predict.load")
|
||||
@patch("utils.weather_sat_predict.TLE_SATELLITES")
|
||||
@patch("utils.weather_sat_predict.wgs84")
|
||||
@patch("utils.weather_sat_predict.EarthSatellite")
|
||||
@patch("utils.weather_sat_predict.find_discrete")
|
||||
def test_pass_data_fields(self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load):
|
||||
"""Pass data should contain all required fields."""
|
||||
mock_ts = MagicMock()
|
||||
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
@@ -646,9 +626,9 @@ class TestPassDataStructure:
|
||||
mock_load.timescale.return_value = mock_ts
|
||||
|
||||
mock_tle.get.return_value = (
|
||||
'NOAA-18',
|
||||
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||
"NOAA-18",
|
||||
"1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999",
|
||||
"2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000",
|
||||
)
|
||||
|
||||
mock_observer = MagicMock()
|
||||
@@ -684,17 +664,27 @@ class TestPassDataStructure:
|
||||
|
||||
# Check all required fields
|
||||
required_fields = [
|
||||
'id', 'satellite', 'name', 'frequency', 'mode',
|
||||
'startTime', 'startTimeISO', 'endTimeISO',
|
||||
'maxEl', 'maxElAz', 'riseAz', 'setAz',
|
||||
'duration', 'quality'
|
||||
"id",
|
||||
"satellite",
|
||||
"name",
|
||||
"frequency",
|
||||
"mode",
|
||||
"startTime",
|
||||
"startTimeISO",
|
||||
"endTimeISO",
|
||||
"maxEl",
|
||||
"maxElAz",
|
||||
"riseAz",
|
||||
"setAz",
|
||||
"duration",
|
||||
"quality",
|
||||
]
|
||||
for field in required_fields:
|
||||
assert field in pass_data, f"Missing required field: {field}"
|
||||
|
||||
def test_import_error_propagates(self):
|
||||
"""predict_passes() should raise ImportError if skyfield unavailable."""
|
||||
with patch.dict('sys.modules', {'skyfield': None, 'skyfield.api': None}):
|
||||
with patch.dict("sys.modules", {"skyfield": None, "skyfield.api": None}):
|
||||
with pytest.raises((ImportError, AttributeError)):
|
||||
predict_passes(lat=51.5, lon=-0.1)
|
||||
|
||||
@@ -706,11 +696,11 @@ class TestTimestampFormatting:
|
||||
"""Aware UTC datetimes should not get a duplicate UTC suffix."""
|
||||
dt = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
value = _format_utc_iso(dt)
|
||||
assert value == '2024-01-01T12:00:00Z'
|
||||
assert '+00:00Z' not in value
|
||||
assert value == "2024-01-01T12:00:00Z"
|
||||
assert "+00:00Z" not in value
|
||||
|
||||
def test_format_utc_iso_from_naive_datetime(self):
|
||||
"""Naive datetimes should be treated as UTC and serialized consistently."""
|
||||
dt = datetime(2024, 1, 1, 12, 0, 0)
|
||||
value = _format_utc_iso(dt)
|
||||
assert value == '2024-01-01T12:00:00Z'
|
||||
assert value == "2024-01-01T12:00:00Z"
|
||||
|
||||
+340
-379
File diff suppressed because it is too large
Load Diff
+39
-53
@@ -8,7 +8,7 @@ import logging
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger('intercept.agent_client')
|
||||
logger = logging.getLogger("intercept.agent_client")
|
||||
|
||||
|
||||
class AgentHTTPError(RuntimeError):
|
||||
@@ -21,18 +21,14 @@ class AgentHTTPError(RuntimeError):
|
||||
|
||||
class AgentConnectionError(AgentHTTPError):
|
||||
"""Exception raised when agent is unreachable."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentClient:
|
||||
"""HTTP client for communicating with a remote Intercept agent."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str | None = None,
|
||||
timeout: float = 60.0
|
||||
):
|
||||
def __init__(self, base_url: str, api_key: str | None = None, timeout: float = 60.0):
|
||||
"""
|
||||
Initialize agent client.
|
||||
|
||||
@@ -41,15 +37,15 @@ class AgentClient:
|
||||
api_key: Optional API key for authentication
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
|
||||
def _headers(self) -> dict:
|
||||
"""Get request headers."""
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_key:
|
||||
headers['X-API-Key'] = self.api_key
|
||||
headers["X-API-Key"] = self.api_key
|
||||
return headers
|
||||
|
||||
def _get(self, path: str, params: dict | None = None) -> dict:
|
||||
@@ -69,12 +65,7 @@ class AgentClient:
|
||||
"""
|
||||
url = f"{self.base_url}{path}"
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=self._headers(),
|
||||
params=params,
|
||||
timeout=self.timeout
|
||||
)
|
||||
response = requests.get(url, headers=self._headers(), params=params, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
return response.json() if response.content else {}
|
||||
except requests.ConnectionError as e:
|
||||
@@ -86,10 +77,10 @@ class AgentClient:
|
||||
error_msg = f"Agent returned error: {e.response.status_code}"
|
||||
try:
|
||||
error_data = e.response.json()
|
||||
if 'message' in error_data:
|
||||
error_msg = error_data['message']
|
||||
elif 'error' in error_data:
|
||||
error_msg = error_data['error']
|
||||
if "message" in error_data:
|
||||
error_msg = error_data["message"]
|
||||
elif "error" in error_data:
|
||||
error_msg = error_data["error"]
|
||||
except Exception:
|
||||
pass
|
||||
raise AgentHTTPError(error_msg, status_code=e.response.status_code)
|
||||
@@ -114,12 +105,7 @@ class AgentClient:
|
||||
url = f"{self.base_url}{path}"
|
||||
request_timeout = self.timeout if timeout is None else timeout
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
json=data or {},
|
||||
headers=self._headers(),
|
||||
timeout=request_timeout
|
||||
)
|
||||
response = requests.post(url, json=data or {}, headers=self._headers(), timeout=request_timeout)
|
||||
response.raise_for_status()
|
||||
return response.json() if response.content else {}
|
||||
except requests.ConnectionError as e:
|
||||
@@ -131,16 +117,20 @@ class AgentClient:
|
||||
error_msg = f"Agent returned error: {e.response.status_code}"
|
||||
try:
|
||||
error_data = e.response.json()
|
||||
if 'message' in error_data:
|
||||
error_msg = error_data['message']
|
||||
elif 'error' in error_data:
|
||||
error_msg = error_data['error']
|
||||
if "message" in error_data:
|
||||
error_msg = error_data["message"]
|
||||
elif "error" in error_data:
|
||||
error_msg = error_data["error"]
|
||||
except Exception:
|
||||
pass
|
||||
raise AgentHTTPError(error_msg, status_code=e.response.status_code)
|
||||
except requests.RequestException as e:
|
||||
raise AgentHTTPError(f"Request failed: {e}")
|
||||
|
||||
def get(self, path: str, params: dict | None = None) -> dict:
|
||||
"""Public GET method for arbitrary endpoints."""
|
||||
return self._get(path, params)
|
||||
|
||||
def post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict:
|
||||
"""Public POST method for arbitrary endpoints."""
|
||||
return self._post(path, data, timeout=timeout)
|
||||
@@ -156,7 +146,7 @@ class AgentClient:
|
||||
Returns:
|
||||
Dict with 'modes' (mode -> bool), 'devices' (list), 'agent_version'
|
||||
"""
|
||||
return self._get('/capabilities')
|
||||
return self._get("/capabilities")
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""
|
||||
@@ -165,7 +155,7 @@ class AgentClient:
|
||||
Returns:
|
||||
Dict with 'running_modes', 'uptime', 'push_enabled', etc.
|
||||
"""
|
||||
return self._get('/status')
|
||||
return self._get("/status")
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""
|
||||
@@ -175,14 +165,14 @@ class AgentClient:
|
||||
True if agent is reachable and healthy
|
||||
"""
|
||||
try:
|
||||
result = self._get('/health')
|
||||
return result.get('status') == 'healthy'
|
||||
result = self._get("/health")
|
||||
return result.get("status") == "healthy"
|
||||
except (AgentHTTPError, AgentConnectionError):
|
||||
return False
|
||||
|
||||
def get_config(self) -> dict:
|
||||
"""Get agent configuration (non-sensitive fields)."""
|
||||
return self._get('/config')
|
||||
return self._get("/config")
|
||||
|
||||
def update_config(self, **kwargs) -> dict:
|
||||
"""
|
||||
@@ -195,7 +185,7 @@ class AgentClient:
|
||||
Returns:
|
||||
Updated config
|
||||
"""
|
||||
return self._post('/config', kwargs)
|
||||
return self._post("/config", kwargs)
|
||||
|
||||
# =========================================================================
|
||||
# Mode Operations
|
||||
@@ -212,7 +202,7 @@ class AgentClient:
|
||||
Returns:
|
||||
Start result with 'status' field
|
||||
"""
|
||||
return self._post(f'/{mode}/start', params or {})
|
||||
return self._post(f"/{mode}/start", params or {})
|
||||
|
||||
def stop_mode(self, mode: str, timeout: float = 8.0) -> dict:
|
||||
"""
|
||||
@@ -224,7 +214,7 @@ class AgentClient:
|
||||
Returns:
|
||||
Stop result with 'status' field
|
||||
"""
|
||||
return self._post(f'/{mode}/stop', timeout=timeout)
|
||||
return self._post(f"/{mode}/stop", timeout=timeout)
|
||||
|
||||
def get_mode_status(self, mode: str) -> dict:
|
||||
"""
|
||||
@@ -236,7 +226,7 @@ class AgentClient:
|
||||
Returns:
|
||||
Mode status with 'running' field
|
||||
"""
|
||||
return self._get(f'/{mode}/status')
|
||||
return self._get(f"/{mode}/status")
|
||||
|
||||
def get_mode_data(self, mode: str) -> dict:
|
||||
"""
|
||||
@@ -248,7 +238,7 @@ class AgentClient:
|
||||
Returns:
|
||||
Data snapshot with 'data' field
|
||||
"""
|
||||
return self._get(f'/{mode}/data')
|
||||
return self._get(f"/{mode}/data")
|
||||
|
||||
# =========================================================================
|
||||
# Convenience Methods
|
||||
@@ -262,17 +252,17 @@ class AgentClient:
|
||||
Dict with capabilities, status, and config
|
||||
"""
|
||||
metadata = {
|
||||
'capabilities': None,
|
||||
'status': None,
|
||||
'config': None,
|
||||
'healthy': False,
|
||||
"capabilities": None,
|
||||
"status": None,
|
||||
"config": None,
|
||||
"healthy": False,
|
||||
}
|
||||
|
||||
try:
|
||||
metadata['capabilities'] = self.get_capabilities()
|
||||
metadata['status'] = self.get_status()
|
||||
metadata['config'] = self.get_config()
|
||||
metadata['healthy'] = True
|
||||
metadata["capabilities"] = self.get_capabilities()
|
||||
metadata["status"] = self.get_status()
|
||||
metadata["config"] = self.get_config()
|
||||
metadata["healthy"] = True
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
logger.warning(f"Failed to refresh agent metadata: {e}")
|
||||
|
||||
@@ -292,8 +282,4 @@ def create_client_from_agent(agent: dict) -> AgentClient:
|
||||
Returns:
|
||||
Configured AgentClient
|
||||
"""
|
||||
return AgentClient(
|
||||
base_url=agent['base_url'],
|
||||
api_key=agent.get('api_key'),
|
||||
timeout=60.0
|
||||
)
|
||||
return AgentClient(base_url=agent["base_url"], api_key=agent.get("api_key"), timeout=60.0)
|
||||
|
||||
@@ -30,12 +30,15 @@ class HeuristicsEngine:
|
||||
- has_random_address: Uses privacy-preserving random address
|
||||
"""
|
||||
|
||||
def evaluate(self, device: BTDeviceAggregate) -> None:
|
||||
def evaluate(self, device: BTDeviceAggregate) -> BTDeviceAggregate:
|
||||
"""
|
||||
Evaluate all heuristics for a device and update its flags.
|
||||
|
||||
Args:
|
||||
device: The BTDeviceAggregate to evaluate.
|
||||
|
||||
Returns:
|
||||
The same device instance with updated heuristic flags.
|
||||
"""
|
||||
# Note: is_new and has_random_address are set by the aggregator
|
||||
# Here we evaluate the behavioral heuristics
|
||||
@@ -43,6 +46,7 @@ class HeuristicsEngine:
|
||||
device.is_persistent = self._check_persistent(device)
|
||||
device.is_beacon_like = self._check_beacon_like(device)
|
||||
device.is_strong_stable = self._check_strong_stable(device)
|
||||
return device
|
||||
|
||||
def _check_persistent(self, device: BTDeviceAggregate) -> bool:
|
||||
"""
|
||||
@@ -134,45 +138,36 @@ class HeuristicsEngine:
|
||||
Returns:
|
||||
Dictionary with heuristic flags and explanations.
|
||||
"""
|
||||
summary = {
|
||||
'flags': [],
|
||||
'details': {}
|
||||
}
|
||||
summary = {"flags": [], "details": {}}
|
||||
|
||||
if device.is_new:
|
||||
summary['flags'].append('new')
|
||||
summary['details']['new'] = 'Device appeared after baseline was set'
|
||||
summary["flags"].append("new")
|
||||
summary["details"]["new"] = "Device appeared after baseline was set"
|
||||
|
||||
if device.is_persistent:
|
||||
summary['flags'].append('persistent')
|
||||
summary['details']['persistent'] = (
|
||||
f'Seen {device.seen_count} times over '
|
||||
f'{device.duration_seconds:.0f}s ({device.seen_rate:.1f}/min)'
|
||||
summary["flags"].append("persistent")
|
||||
summary["details"]["persistent"] = (
|
||||
f"Seen {device.seen_count} times over {device.duration_seconds:.0f}s ({device.seen_rate:.1f}/min)"
|
||||
)
|
||||
|
||||
if device.is_beacon_like:
|
||||
summary['flags'].append('beacon_like')
|
||||
summary["flags"].append("beacon_like")
|
||||
intervals = self._calculate_intervals(device)
|
||||
if intervals:
|
||||
mean_int = statistics.mean(intervals)
|
||||
summary['details']['beacon_like'] = (
|
||||
f'Regular advertising interval (~{mean_int:.1f}s)'
|
||||
)
|
||||
summary["details"]["beacon_like"] = f"Regular advertising interval (~{mean_int:.1f}s)"
|
||||
else:
|
||||
summary['details']['beacon_like'] = 'Regular advertising pattern'
|
||||
summary["details"]["beacon_like"] = "Regular advertising pattern"
|
||||
|
||||
if device.is_strong_stable:
|
||||
summary['flags'].append('strong_stable')
|
||||
summary['details']['strong_stable'] = (
|
||||
f'Strong signal ({device.rssi_median:.0f} dBm) '
|
||||
f'with low variance ({device.rssi_variance:.1f})'
|
||||
summary["flags"].append("strong_stable")
|
||||
summary["details"]["strong_stable"] = (
|
||||
f"Strong signal ({device.rssi_median:.0f} dBm) with low variance ({device.rssi_variance:.1f})"
|
||||
)
|
||||
|
||||
if device.has_random_address:
|
||||
summary['flags'].append('random_address')
|
||||
summary['details']['random_address'] = (
|
||||
f'Uses {device.address_type} address (privacy-preserving)'
|
||||
)
|
||||
summary["flags"].append("random_address")
|
||||
summary["details"]["random_address"] = f"Uses {device.address_type} address (privacy-preserving)"
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
@@ -19,35 +19,38 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger('intercept.bluetooth.tracker_signatures')
|
||||
logger = logging.getLogger("intercept.bluetooth.tracker_signatures")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TRACKER TYPES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TrackerType(str, Enum):
|
||||
"""Known tracker device types."""
|
||||
AIRTAG = 'airtag'
|
||||
FINDMY_ACCESSORY = 'findmy_accessory'
|
||||
TILE = 'tile'
|
||||
SAMSUNG_SMARTTAG = 'samsung_smarttag'
|
||||
CHIPOLO = 'chipolo'
|
||||
PEBBLEBEE = 'pebblebee'
|
||||
NUTFIND = 'nutfind'
|
||||
ORBIT = 'orbit'
|
||||
EUFY = 'eufy'
|
||||
CUBE = 'cube'
|
||||
UNKNOWN_TRACKER = 'unknown_tracker'
|
||||
NOT_A_TRACKER = 'not_a_tracker'
|
||||
|
||||
AIRTAG = "airtag"
|
||||
FINDMY_ACCESSORY = "findmy_accessory"
|
||||
TILE = "tile"
|
||||
SAMSUNG_SMARTTAG = "samsung_smarttag"
|
||||
CHIPOLO = "chipolo"
|
||||
PEBBLEBEE = "pebblebee"
|
||||
NUTFIND = "nutfind"
|
||||
ORBIT = "orbit"
|
||||
EUFY = "eufy"
|
||||
CUBE = "cube"
|
||||
UNKNOWN_TRACKER = "unknown_tracker"
|
||||
NOT_A_TRACKER = "not_a_tracker"
|
||||
|
||||
|
||||
class TrackerConfidence(str, Enum):
|
||||
"""Confidence level for tracker detection."""
|
||||
HIGH = 'high' # Multiple strong indicators match
|
||||
MEDIUM = 'medium' # Some indicators match
|
||||
LOW = 'low' # Weak indicators, needs investigation
|
||||
NONE = 'none' # Not detected as tracker
|
||||
|
||||
HIGH = "high" # Multiple strong indicators match
|
||||
MEDIUM = "medium" # Some indicators match
|
||||
LOW = "low" # Weak indicators, needs investigation
|
||||
NONE = "none" # Not detected as tracker
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -65,28 +68,28 @@ APPLE_FINDMY_PREFIX_SHORT = bytes([0x12]) # Find My prefix (short)
|
||||
APPLE_FINDMY_PREFIX_ALT = bytes([0x07, 0x19]) # Alternative Find My pattern
|
||||
|
||||
# Find My service UUID (Apple's offline finding service)
|
||||
APPLE_FINDMY_SERVICE_UUID = 'fd6f' # 16-bit UUID
|
||||
APPLE_CONTINUITY_SERVICE_UUID = 'd0611e78-bbb4-4591-a5f8-487910ae4366'
|
||||
APPLE_FINDMY_SERVICE_UUID = "fd6f" # 16-bit UUID
|
||||
APPLE_CONTINUITY_SERVICE_UUID = "d0611e78-bbb4-4591-a5f8-487910ae4366"
|
||||
|
||||
# Tile
|
||||
TILE_COMPANY_ID = 0x00ED # Tile Inc
|
||||
TILE_ALT_COMPANY_ID = 0x038F # Alternative Tile ID
|
||||
TILE_SERVICE_UUID = 'feed' # Tile service UUID (16-bit)
|
||||
TILE_MAC_PREFIXES = ['C4:E7', 'DC:54', 'E4:B0', 'F8:8A', 'E6:43', '90:32', 'D0:72']
|
||||
TILE_SERVICE_UUID = "feed" # Tile service UUID (16-bit)
|
||||
TILE_MAC_PREFIXES = ["C4:E7", "DC:54", "E4:B0", "F8:8A", "E6:43", "90:32", "D0:72"]
|
||||
|
||||
# Samsung SmartTag
|
||||
SAMSUNG_COMPANY_ID = 0x0075
|
||||
SMARTTAG_SERVICE_UUID = 'fd5a' # SmartThings Find service
|
||||
SMARTTAG_MAC_PREFIXES = ['58:4D', 'A0:75', 'B8:D7', '50:32']
|
||||
SMARTTAG_SERVICE_UUID = "fd5a" # SmartThings Find service
|
||||
SMARTTAG_MAC_PREFIXES = ["58:4D", "A0:75", "B8:D7", "50:32"]
|
||||
|
||||
# Chipolo
|
||||
CHIPOLO_COMPANY_ID = 0x0A09
|
||||
CHIPOLO_SERVICE_UUID = 'feaa' # Eddystone beacon (used by some Chipolo)
|
||||
CHIPOLO_ALT_SERVICE = 'feb1'
|
||||
CHIPOLO_SERVICE_UUID = "feaa" # Eddystone beacon (used by some Chipolo)
|
||||
CHIPOLO_ALT_SERVICE = "feb1"
|
||||
|
||||
# PebbleBee
|
||||
PEBBLEBEE_SERVICE_UUID = 'feab'
|
||||
PEBBLEBEE_MAC_PREFIXES = ['D4:3D', 'E0:E5']
|
||||
PEBBLEBEE_SERVICE_UUID = "feab"
|
||||
PEBBLEBEE_MAC_PREFIXES = ["D4:3D", "E0:E5"]
|
||||
|
||||
# Other known trackers
|
||||
NUTFIND_COMPANY_ID = 0x0A09
|
||||
@@ -94,16 +97,17 @@ EUFY_COMPANY_ID = 0x0590
|
||||
|
||||
# Generic beacon patterns that may indicate a tracker
|
||||
BEACON_SERVICE_UUIDS = [
|
||||
'feaa', # Eddystone
|
||||
'feab', # Nokia beacon
|
||||
'feb1', # Dialog Semiconductor
|
||||
'febe', # Bose
|
||||
"feaa", # Eddystone
|
||||
"feab", # Nokia beacon
|
||||
"feb1", # Dialog Semiconductor
|
||||
"febe", # Bose
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackerSignature:
|
||||
"""Defines a tracker signature pattern."""
|
||||
|
||||
tracker_type: TrackerType
|
||||
name: str
|
||||
description: str
|
||||
@@ -123,82 +127,76 @@ TRACKER_SIGNATURES: list[TrackerSignature] = [
|
||||
# Apple AirTag
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.AIRTAG,
|
||||
name='Apple AirTag',
|
||||
description='Apple AirTag tracking device using Find My network',
|
||||
name="Apple AirTag",
|
||||
description="Apple AirTag tracking device using Find My network",
|
||||
company_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data_prefixes=[
|
||||
APPLE_AIRTAG_ADV_PATTERN,
|
||||
APPLE_FINDMY_PREFIX_SHORT,
|
||||
],
|
||||
service_uuids=[APPLE_FINDMY_SERVICE_UUID],
|
||||
name_patterns=['airtag'],
|
||||
name_patterns=["airtag"],
|
||||
min_manufacturer_data_len=22, # AirTags have 22+ byte payloads
|
||||
confidence_boost=0.2,
|
||||
),
|
||||
|
||||
# Apple Find My Accessory (non-AirTag)
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.FINDMY_ACCESSORY,
|
||||
name='Find My Accessory',
|
||||
description='Third-party Apple Find My network accessory',
|
||||
name="Find My Accessory",
|
||||
description="Third-party Apple Find My network accessory",
|
||||
company_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data_prefixes=[
|
||||
APPLE_FINDMY_PREFIX_SHORT,
|
||||
APPLE_FINDMY_PREFIX_ALT,
|
||||
],
|
||||
service_uuids=[APPLE_FINDMY_SERVICE_UUID],
|
||||
name_patterns=['findmy', 'find my', 'chipolo one spot', 'belkin'],
|
||||
name_patterns=["findmy", "find my", "chipolo one spot", "belkin"],
|
||||
),
|
||||
|
||||
# Tile
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.TILE,
|
||||
name='Tile Tracker',
|
||||
description='Tile Bluetooth tracker',
|
||||
name="Tile Tracker",
|
||||
description="Tile Bluetooth tracker",
|
||||
company_ids=[TILE_COMPANY_ID, TILE_ALT_COMPANY_ID],
|
||||
service_uuids=[TILE_SERVICE_UUID],
|
||||
mac_prefixes=TILE_MAC_PREFIXES,
|
||||
name_patterns=['tile'],
|
||||
name_patterns=["tile"],
|
||||
),
|
||||
|
||||
# Samsung SmartTag
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.SAMSUNG_SMARTTAG,
|
||||
name='Samsung SmartTag',
|
||||
description='Samsung SmartThings tracker',
|
||||
name="Samsung SmartTag",
|
||||
description="Samsung SmartThings tracker",
|
||||
company_id=SAMSUNG_COMPANY_ID,
|
||||
service_uuids=[SMARTTAG_SERVICE_UUID],
|
||||
mac_prefixes=SMARTTAG_MAC_PREFIXES,
|
||||
name_patterns=['smarttag', 'smart tag', 'galaxy tag'],
|
||||
name_patterns=["smarttag", "smart tag", "galaxy tag"],
|
||||
),
|
||||
|
||||
# Chipolo
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.CHIPOLO,
|
||||
name='Chipolo',
|
||||
description='Chipolo Bluetooth tracker',
|
||||
name="Chipolo",
|
||||
description="Chipolo Bluetooth tracker",
|
||||
company_id=CHIPOLO_COMPANY_ID,
|
||||
service_uuids=[CHIPOLO_SERVICE_UUID, CHIPOLO_ALT_SERVICE],
|
||||
name_patterns=['chipolo'],
|
||||
name_patterns=["chipolo"],
|
||||
),
|
||||
|
||||
# PebbleBee
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.PEBBLEBEE,
|
||||
name='PebbleBee',
|
||||
description='PebbleBee Bluetooth tracker',
|
||||
name="PebbleBee",
|
||||
description="PebbleBee Bluetooth tracker",
|
||||
service_uuids=[PEBBLEBEE_SERVICE_UUID],
|
||||
mac_prefixes=PEBBLEBEE_MAC_PREFIXES,
|
||||
name_patterns=['pebblebee', 'pebble bee', 'honey'],
|
||||
name_patterns=["pebblebee", "pebble bee", "honey"],
|
||||
),
|
||||
|
||||
# Eufy
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.EUFY,
|
||||
name='Eufy SmartTrack',
|
||||
description='Eufy/Anker smart tracker',
|
||||
name="Eufy SmartTrack",
|
||||
description="Eufy/Anker smart tracker",
|
||||
company_id=EUFY_COMPANY_ID,
|
||||
name_patterns=['eufy', 'smarttrack'],
|
||||
name_patterns=["eufy", "smarttrack"],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -207,13 +205,14 @@ TRACKER_SIGNATURES: list[TrackerSignature] = [
|
||||
# TRACKER DETECTION RESULT
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackerDetectionResult:
|
||||
"""Result of tracker detection analysis."""
|
||||
|
||||
is_tracker: bool = False
|
||||
tracker_type: TrackerType = TrackerType.NOT_A_TRACKER
|
||||
tracker_name: str = ''
|
||||
tracker_name: str = ""
|
||||
confidence: TrackerConfidence = TrackerConfidence.NONE
|
||||
confidence_score: float = 0.0 # 0.0 to 1.0
|
||||
evidence: list[str] = field(default_factory=list)
|
||||
@@ -231,18 +230,18 @@ class TrackerDetectionResult:
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'is_tracker': self.is_tracker,
|
||||
'tracker_type': self.tracker_type.value if self.tracker_type else None,
|
||||
'tracker_name': self.tracker_name,
|
||||
'confidence': self.confidence.value if self.confidence else None,
|
||||
'confidence_score': round(self.confidence_score, 2),
|
||||
'evidence': self.evidence,
|
||||
'matched_signature': self.matched_signature,
|
||||
'risk_factors': self.risk_factors,
|
||||
'risk_score': round(self.risk_score, 2),
|
||||
'manufacturer_id': self.manufacturer_id,
|
||||
'manufacturer_data_hex': self.manufacturer_data_hex,
|
||||
'service_uuids_found': self.service_uuids_found,
|
||||
"is_tracker": self.is_tracker,
|
||||
"tracker_type": self.tracker_type.value if self.tracker_type else None,
|
||||
"tracker_name": self.tracker_name,
|
||||
"confidence": self.confidence.value if self.confidence else None,
|
||||
"confidence_score": round(self.confidence_score, 2),
|
||||
"evidence": self.evidence,
|
||||
"matched_signature": self.matched_signature,
|
||||
"risk_factors": self.risk_factors,
|
||||
"risk_score": round(self.risk_score, 2),
|
||||
"manufacturer_id": self.manufacturer_id,
|
||||
"manufacturer_data_hex": self.manufacturer_data_hex,
|
||||
"service_uuids_found": self.service_uuids_found,
|
||||
}
|
||||
|
||||
|
||||
@@ -250,6 +249,7 @@ class TrackerDetectionResult:
|
||||
# DEVICE FINGERPRINT (survives MAC randomization)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceFingerprint:
|
||||
"""
|
||||
@@ -277,15 +277,15 @@ class DeviceFingerprint:
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'fingerprint_id': self.fingerprint_id,
|
||||
'manufacturer_id': self.manufacturer_id,
|
||||
'manufacturer_data_prefix': self.manufacturer_data_prefix.hex() if self.manufacturer_data_prefix else None,
|
||||
'manufacturer_data_length': self.manufacturer_data_length,
|
||||
'service_uuids': self.service_uuids,
|
||||
'service_data_keys': self.service_data_keys,
|
||||
'tx_power_bucket': self.tx_power_bucket,
|
||||
'name_hint': self.name_hint,
|
||||
'stability_confidence': round(self.stability_confidence, 2),
|
||||
"fingerprint_id": self.fingerprint_id,
|
||||
"manufacturer_id": self.manufacturer_id,
|
||||
"manufacturer_data_prefix": self.manufacturer_data_prefix.hex() if self.manufacturer_data_prefix else None,
|
||||
"manufacturer_data_length": self.manufacturer_data_length,
|
||||
"service_uuids": self.service_uuids,
|
||||
"service_data_keys": self.service_data_keys,
|
||||
"tx_power_bucket": self.tx_power_bucket,
|
||||
"name_hint": self.name_hint,
|
||||
"stability_confidence": round(self.stability_confidence, 2),
|
||||
}
|
||||
|
||||
|
||||
@@ -316,39 +316,39 @@ def generate_fingerprint(
|
||||
mfr_length = 0
|
||||
|
||||
if manufacturer_id is not None:
|
||||
features.append(f'mfr:{manufacturer_id:04x}')
|
||||
features.append(f"mfr:{manufacturer_id:04x}")
|
||||
stability_score += 0.2
|
||||
|
||||
if manufacturer_data:
|
||||
mfr_length = len(manufacturer_data)
|
||||
features.append(f'mfr_len:{mfr_length}')
|
||||
features.append(f"mfr_len:{mfr_length}")
|
||||
stability_score += 0.1
|
||||
|
||||
# First 4 bytes of manufacturer data are often stable
|
||||
mfr_prefix = manufacturer_data[:min(4, len(manufacturer_data))]
|
||||
features.append(f'mfr_pfx:{mfr_prefix.hex()}')
|
||||
mfr_prefix = manufacturer_data[: min(4, len(manufacturer_data))]
|
||||
features.append(f"mfr_pfx:{mfr_prefix.hex()}")
|
||||
stability_score += 0.2
|
||||
|
||||
sorted_uuids = sorted(service_uuids)
|
||||
if sorted_uuids:
|
||||
features.append(f'uuids:{",".join(sorted_uuids)}')
|
||||
features.append(f"uuids:{','.join(sorted_uuids)}")
|
||||
stability_score += 0.2
|
||||
|
||||
sd_keys = sorted(service_data.keys())
|
||||
if sd_keys:
|
||||
features.append(f'sd_keys:{",".join(sd_keys)}')
|
||||
features.append(f"sd_keys:{','.join(sd_keys)}")
|
||||
stability_score += 0.1
|
||||
|
||||
# TX power bucket
|
||||
tx_bucket = None
|
||||
if tx_power is not None:
|
||||
if tx_power >= 0:
|
||||
tx_bucket = 'high'
|
||||
tx_bucket = "high"
|
||||
elif tx_power >= -10:
|
||||
tx_bucket = 'medium'
|
||||
tx_bucket = "medium"
|
||||
else:
|
||||
tx_bucket = 'low'
|
||||
features.append(f'tx:{tx_bucket}')
|
||||
tx_bucket = "low"
|
||||
features.append(f"tx:{tx_bucket}")
|
||||
stability_score += 0.05
|
||||
|
||||
# Name hint (for devices that advertise names)
|
||||
@@ -357,11 +357,11 @@ def generate_fingerprint(
|
||||
# Only use first word of name (often stable)
|
||||
name_hint = name.split()[0].lower() if name else None
|
||||
if name_hint:
|
||||
features.append(f'name:{name_hint}')
|
||||
features.append(f"name:{name_hint}")
|
||||
stability_score += 0.15
|
||||
|
||||
# Generate fingerprint ID
|
||||
feature_str = '|'.join(features)
|
||||
feature_str = "|".join(features)
|
||||
fingerprint_id = hashlib.sha256(feature_str.encode()).hexdigest()[:16]
|
||||
|
||||
return DeviceFingerprint(
|
||||
@@ -381,6 +381,7 @@ def generate_fingerprint(
|
||||
# TRACKER DETECTION ENGINE
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TrackerSignatureEngine:
|
||||
"""
|
||||
Engine for detecting known BLE trackers from advertising data.
|
||||
@@ -485,7 +486,7 @@ class TrackerSignatureEngine:
|
||||
result.matched_signature = best_match.name
|
||||
else:
|
||||
result.tracker_type = TrackerType.UNKNOWN_TRACKER
|
||||
result.tracker_name = 'Unknown Tracker'
|
||||
result.tracker_name = "Unknown Tracker"
|
||||
|
||||
# Determine confidence level
|
||||
if best_score >= 0.7:
|
||||
@@ -534,32 +535,35 @@ class TrackerSignatureEngine:
|
||||
|
||||
if has_findmy_pattern or has_findmy_service:
|
||||
score += 0.35
|
||||
evidence.append(f'Manufacturer ID 0x{manufacturer_id:04X} matches {signature.name}')
|
||||
evidence.append(f"Manufacturer ID 0x{manufacturer_id:04X} matches {signature.name}")
|
||||
# Don't add score for Apple manufacturer ID without Find My indicators
|
||||
else:
|
||||
# Non-Apple trackers - company ID is strong evidence
|
||||
score += 0.35
|
||||
evidence.append(f'Manufacturer ID 0x{manufacturer_id:04X} matches {signature.name}')
|
||||
evidence.append(f"Manufacturer ID 0x{manufacturer_id:04X} matches {signature.name}")
|
||||
|
||||
# Check manufacturer data prefix (high weight for specific patterns)
|
||||
if manufacturer_data and signature.manufacturer_data_prefixes:
|
||||
for prefix in signature.manufacturer_data_prefixes:
|
||||
if manufacturer_data.startswith(prefix):
|
||||
score += 0.30
|
||||
evidence.append(f'Manufacturer data pattern matches {signature.name}')
|
||||
evidence.append(f"Manufacturer data pattern matches {signature.name}")
|
||||
break
|
||||
|
||||
# Check manufacturer data length
|
||||
if manufacturer_data and signature.min_manufacturer_data_len > 0:
|
||||
# Check manufacturer data length (corroborative - only counts alongside
|
||||
# an identifying indicator, mirroring _check_generic_tracker_indicators)
|
||||
if manufacturer_data and signature.min_manufacturer_data_len > 0 and score > 0:
|
||||
if len(manufacturer_data) >= signature.min_manufacturer_data_len:
|
||||
score += 0.10
|
||||
evidence.append(f'Manufacturer data length ({len(manufacturer_data)} bytes) consistent with {signature.name}')
|
||||
evidence.append(
|
||||
f"Manufacturer data length ({len(manufacturer_data)} bytes) consistent with {signature.name}"
|
||||
)
|
||||
|
||||
# Check service UUIDs (medium weight)
|
||||
for sig_uuid in signature.service_uuids:
|
||||
if sig_uuid.lower() in normalized_uuids:
|
||||
score += 0.25
|
||||
evidence.append(f'Service UUID {sig_uuid} matches {signature.name}')
|
||||
evidence.append(f"Service UUID {sig_uuid} matches {signature.name}")
|
||||
break
|
||||
|
||||
# Check MAC prefix (medium weight)
|
||||
@@ -568,20 +572,24 @@ class TrackerSignatureEngine:
|
||||
for prefix in signature.mac_prefixes:
|
||||
if mac_upper.startswith(prefix):
|
||||
score += 0.20
|
||||
evidence.append(f'MAC prefix {prefix} matches known {signature.name} range')
|
||||
evidence.append(f"MAC prefix {prefix} matches known {signature.name} range")
|
||||
break
|
||||
|
||||
# Check name patterns (lower weight - can be spoofed)
|
||||
# Check name patterns - a name match alone yields a LOW-confidence
|
||||
# detection (0.30 = detection threshold); names can be spoofed, so it
|
||||
# stays below the company-ID weight
|
||||
if name and signature.name_patterns:
|
||||
name_lower = name.lower()
|
||||
for pattern in signature.name_patterns:
|
||||
if pattern.lower() in name_lower:
|
||||
score += 0.15
|
||||
score += 0.30
|
||||
evidence.append(f'Device name "{name}" contains pattern "{pattern}"')
|
||||
break
|
||||
|
||||
# Apply confidence boost for specific signatures
|
||||
score += signature.confidence_boost
|
||||
# Apply confidence boost for specific signatures, but only when at
|
||||
# least one indicator actually matched - never as a free baseline
|
||||
if score > 0:
|
||||
score += signature.confidence_boost
|
||||
|
||||
return score, evidence
|
||||
|
||||
@@ -600,33 +608,33 @@ class TrackerSignatureEngine:
|
||||
# Apple Find My service UUID without specific AirTag pattern
|
||||
if APPLE_FINDMY_SERVICE_UUID in normalized_uuids:
|
||||
score += 0.4
|
||||
evidence.append('Uses Apple Find My network service (fd6f)')
|
||||
evidence.append("Uses Apple Find My network service (fd6f)")
|
||||
|
||||
# Apple manufacturer with Find My advertisement type
|
||||
if manufacturer_id == APPLE_COMPANY_ID and manufacturer_data and len(manufacturer_data) >= 2:
|
||||
adv_type = manufacturer_data[0]
|
||||
if adv_type == APPLE_FINDMY_ADV_TYPE:
|
||||
score += 0.35
|
||||
evidence.append('Apple Find My network advertisement detected')
|
||||
evidence.append("Apple Find My network advertisement detected")
|
||||
|
||||
# Check for beacon-like service UUIDs
|
||||
for beacon_uuid in BEACON_SERVICE_UUIDS:
|
||||
if beacon_uuid in normalized_uuids:
|
||||
score += 0.15
|
||||
evidence.append(f'Uses beacon service UUID ({beacon_uuid})')
|
||||
evidence.append(f"Uses beacon service UUID ({beacon_uuid})")
|
||||
break
|
||||
|
||||
# Random address (most trackers use random addresses)
|
||||
if address_type in ('random', 'rpa', 'nrpa'):
|
||||
if address_type in ("random", "rpa", "nrpa"):
|
||||
# This is a weak indicator - many devices use random addresses
|
||||
if score > 0: # Only add if other indicators present
|
||||
score += 0.05
|
||||
evidence.append('Uses randomized MAC address')
|
||||
evidence.append("Uses randomized MAC address")
|
||||
|
||||
# Small manufacturer data payload typical of beacons
|
||||
if manufacturer_data and 20 <= len(manufacturer_data) <= 30 and score > 0:
|
||||
score += 0.05
|
||||
evidence.append(f'Manufacturer data length ({len(manufacturer_data)} bytes) typical of beacon')
|
||||
evidence.append(f"Manufacturer data length ({len(manufacturer_data)} bytes) typical of beacon")
|
||||
|
||||
return score, evidence
|
||||
|
||||
@@ -637,7 +645,7 @@ class TrackerSignatureEngine:
|
||||
uuid_lower = uuid.lower()
|
||||
# Extract 16-bit UUID from full 128-bit Bluetooth Base UUID
|
||||
# Format: 0000XXXX-0000-1000-8000-00805f9b34fb
|
||||
if len(uuid_lower) == 36 and uuid_lower.endswith('-0000-1000-8000-00805f9b34fb'):
|
||||
if len(uuid_lower) == 36 and uuid_lower.endswith("-0000-1000-8000-00805f9b34fb"):
|
||||
short_uuid = uuid_lower[4:8]
|
||||
normalized.append(short_uuid)
|
||||
else:
|
||||
@@ -676,10 +684,7 @@ class TrackerSignatureEngine:
|
||||
|
||||
# Keep only last 24 hours of sightings
|
||||
cutoff = ts - timedelta(hours=24)
|
||||
self._sighting_history[fingerprint_id] = [
|
||||
t for t in self._sighting_history[fingerprint_id]
|
||||
if t > cutoff
|
||||
]
|
||||
self._sighting_history[fingerprint_id] = [t for t in self._sighting_history[fingerprint_id] if t > cutoff]
|
||||
|
||||
self._sighting_history[fingerprint_id].append(ts)
|
||||
return len(self._sighting_history[fingerprint_id])
|
||||
@@ -719,39 +724,39 @@ class TrackerSignatureEngine:
|
||||
# Tracker baseline - if it's a tracker, start with some risk
|
||||
if is_tracker:
|
||||
risk_score += 0.3
|
||||
risk_factors.append('Device matches known tracker signature')
|
||||
risk_factors.append("Device matches known tracker signature")
|
||||
|
||||
# Heuristic 1: Persistently near - seen many times over a long period
|
||||
if seen_count >= 20 and duration_seconds >= 600: # 10+ minutes
|
||||
points = min(0.25, (seen_count / 100) * 0.25)
|
||||
risk_score += points
|
||||
risk_factors.append(f'Persistently present: seen {seen_count} times over {duration_seconds/60:.1f} min')
|
||||
risk_factors.append(f"Persistently present: seen {seen_count} times over {duration_seconds / 60:.1f} min")
|
||||
elif seen_count >= 50:
|
||||
risk_score += 0.2
|
||||
risk_factors.append(f'High observation count: {seen_count} sightings')
|
||||
risk_factors.append(f"High observation count: {seen_count} sightings")
|
||||
|
||||
# Heuristic 2: Consistent presence rate (beacon-like behavior)
|
||||
if seen_rate >= 3.0: # 3+ observations per minute
|
||||
points = min(0.15, (seen_rate / 10) * 0.15)
|
||||
risk_score += points
|
||||
risk_factors.append(f'Beacon-like presence: {seen_rate:.1f} obs/min')
|
||||
risk_factors.append(f"Beacon-like presence: {seen_rate:.1f} obs/min")
|
||||
|
||||
# Heuristic 3: Stable RSSI (moving with us, same relative distance)
|
||||
if rssi_variance is not None and rssi_variance < 10:
|
||||
risk_score += 0.1
|
||||
risk_factors.append(f'Stable signal strength (variance: {rssi_variance:.1f})')
|
||||
risk_factors.append(f"Stable signal strength (variance: {rssi_variance:.1f})")
|
||||
|
||||
# Heuristic 4: New device appearing (not in baseline)
|
||||
if is_new and is_tracker:
|
||||
risk_score += 0.15
|
||||
risk_factors.append('New tracker appeared after baseline was set')
|
||||
risk_factors.append("New tracker appeared after baseline was set")
|
||||
|
||||
# Cross-session persistence (from sighting history)
|
||||
historical_count = self.get_sighting_count(fingerprint_id, window_hours=24)
|
||||
if historical_count >= 10:
|
||||
points = min(0.15, (historical_count / 50) * 0.15)
|
||||
risk_score += points
|
||||
risk_factors.append(f'Seen across multiple sessions: {historical_count} total sightings in 24h')
|
||||
risk_factors.append(f"Seen across multiple sessions: {historical_count} total sightings in 24h")
|
||||
|
||||
return min(1.0, risk_score), risk_factors
|
||||
|
||||
@@ -773,7 +778,7 @@ def get_tracker_engine() -> TrackerSignatureEngine:
|
||||
|
||||
def detect_tracker(
|
||||
address: str,
|
||||
address_type: str = 'public',
|
||||
address_type: str = "public",
|
||||
name: str | None = None,
|
||||
manufacturer_id: int | None = None,
|
||||
manufacturer_data: bytes | None = None,
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Shared tool/interface capability detection.
|
||||
|
||||
Extracted from the standalone agent (``intercept_agent.py``) so the app and the
|
||||
agent share one implementation and cannot drift. Mode availability is derived
|
||||
from :func:`utils.dependencies.check_all_dependencies`; interface detection
|
||||
probes the host for WiFi interfaces and Bluetooth adapters.
|
||||
|
||||
This module is intentionally config-agnostic: it reports raw tool/hardware
|
||||
availability. Callers that gate modes behind their own configuration apply that
|
||||
gating on top of the values returned here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from utils.dependencies import check_all_dependencies, check_tool
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger("intercept.capabilities")
|
||||
|
||||
# Mapping from utils.dependencies mode key -> capability/mode key used by callers.
|
||||
MODE_DEPENDENCY_MAP = {
|
||||
"pager": "pager",
|
||||
"sensor": "sensor",
|
||||
"aircraft": "adsb",
|
||||
"ais": "ais",
|
||||
"acars": "acars",
|
||||
"aprs": "aprs",
|
||||
"wifi": "wifi",
|
||||
"bluetooth": "bluetooth",
|
||||
"tscm": "tscm",
|
||||
"satellite": "satellite",
|
||||
}
|
||||
|
||||
# Modes not represented in utils.dependencies; keyed by cap mode -> required tools.
|
||||
EXTRA_MODE_TOOLS = {
|
||||
"dsc": ["rtl_fm"],
|
||||
"rtlamr": ["rtlamr"],
|
||||
"listening_post": ["rtl_fm"],
|
||||
}
|
||||
|
||||
# Fallback tool checks when the dependencies module is unavailable.
|
||||
FALLBACK_TOOL_CHECKS = {
|
||||
"pager": ["rtl_fm", "multimon-ng"],
|
||||
"sensor": ["rtl_433"],
|
||||
"adsb": ["dump1090"],
|
||||
"ais": ["AIS-catcher"],
|
||||
"acars": ["acarsdec"],
|
||||
"aprs": ["rtl_fm", "direwolf"],
|
||||
"wifi": ["airmon-ng", "airodump-ng"],
|
||||
"bluetooth": ["bluetoothctl"],
|
||||
"dsc": ["rtl_fm"],
|
||||
"rtlamr": ["rtlamr"],
|
||||
"satellite": [],
|
||||
"listening_post": ["rtl_fm"],
|
||||
"tscm": ["rtl_fm"],
|
||||
}
|
||||
|
||||
|
||||
def detect_mode_availability(dep_status: dict | None = None) -> dict[str, bool]:
|
||||
"""Detect mode availability from tool dependencies.
|
||||
|
||||
Returns a ``{cap_mode: bool}`` map of raw tool readiness. Falls back to
|
||||
direct tool checks if :func:`check_all_dependencies` raises.
|
||||
|
||||
Args:
|
||||
dep_status: Pre-computed result of :func:`check_all_dependencies`. When
|
||||
supplied the probe is skipped entirely, avoiding a second call when
|
||||
the caller has already fetched it.
|
||||
"""
|
||||
modes: dict[str, bool] = {}
|
||||
try:
|
||||
if dep_status is None:
|
||||
dep_status = check_all_dependencies()
|
||||
except Exception as e:
|
||||
logger.warning(f"Dependency check failed, using fallback: {e}")
|
||||
return _detect_mode_availability_fallback()
|
||||
|
||||
for dep_mode, cap_mode in MODE_DEPENDENCY_MAP.items():
|
||||
if dep_mode in dep_status:
|
||||
modes[cap_mode] = dep_status[dep_mode]["ready"]
|
||||
else:
|
||||
modes[cap_mode] = False
|
||||
|
||||
# Modes not in dependencies.py
|
||||
for cap_mode, tools in EXTRA_MODE_TOOLS.items():
|
||||
modes[cap_mode] = all(check_tool(tool) for tool in tools) if tools else True
|
||||
|
||||
return modes
|
||||
|
||||
|
||||
def _detect_mode_availability_fallback() -> dict[str, bool]:
|
||||
"""Fallback mode availability when the dependencies module is unavailable.
|
||||
|
||||
Note: this uses ``utils.dependencies.check_tool``, which also searches
|
||||
Homebrew paths (a strict superset of ``shutil.which``).
|
||||
"""
|
||||
modes: dict[str, bool] = {}
|
||||
for mode, tools in FALLBACK_TOOL_CHECKS.items():
|
||||
if not tools:
|
||||
modes[mode] = True
|
||||
elif mode == "adsb":
|
||||
modes[mode] = check_tool("dump1090") or check_tool("dump1090-fa") or check_tool("readsb")
|
||||
else:
|
||||
modes[mode] = all(check_tool(tool) for tool in tools)
|
||||
return modes
|
||||
|
||||
|
||||
def detect_interfaces() -> dict[str, list]:
|
||||
"""Detect WiFi interfaces and Bluetooth adapters on the host.
|
||||
|
||||
Returns ``{"wifi_interfaces": [...], "bt_adapters": [...], "sdr_devices": []}``.
|
||||
``sdr_devices`` is left empty here; SDR enumeration is handled by callers.
|
||||
"""
|
||||
interfaces: dict[str, list] = {
|
||||
"wifi_interfaces": [],
|
||||
"bt_adapters": [],
|
||||
"sdr_devices": [],
|
||||
}
|
||||
|
||||
# Detect WiFi interfaces
|
||||
if platform.system() == "Darwin": # macOS
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["networksetup", "-listallhardwareports"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
lines = result.stdout.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if "Wi-Fi" in line or "AirPort" in line:
|
||||
port_name = line.replace("Hardware Port:", "").strip()
|
||||
for j in range(i + 1, min(i + 3, len(lines))):
|
||||
if "Device:" in lines[j]:
|
||||
device = lines[j].split("Device:")[1].strip()
|
||||
interfaces["wifi_interfaces"].append(
|
||||
{
|
||||
"name": device,
|
||||
"display_name": f"{port_name} ({device})",
|
||||
"type": "internal",
|
||||
"monitor_capable": False,
|
||||
}
|
||||
)
|
||||
break
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
pass
|
||||
else: # Linux
|
||||
try:
|
||||
result = subprocess.run(["iw", "dev"], capture_output=True, text=True, timeout=5)
|
||||
current_iface = None
|
||||
for line in result.stdout.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("Interface"):
|
||||
current_iface = line.split()[1]
|
||||
elif current_iface and "type" in line:
|
||||
iface_type = line.split()[-1]
|
||||
interfaces["wifi_interfaces"].append(
|
||||
{
|
||||
"name": current_iface,
|
||||
"display_name": f"Wireless ({current_iface}) - {iface_type}",
|
||||
"type": iface_type,
|
||||
"monitor_capable": True,
|
||||
}
|
||||
)
|
||||
current_iface = None
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
# Fall back to iwconfig
|
||||
try:
|
||||
result = subprocess.run(["iwconfig"], capture_output=True, text=True, timeout=5)
|
||||
for line in result.stdout.split("\n"):
|
||||
if "IEEE 802.11" in line:
|
||||
iface = line.split()[0]
|
||||
interfaces["wifi_interfaces"].append(
|
||||
{
|
||||
"name": iface,
|
||||
"display_name": f"Wireless ({iface})",
|
||||
"type": "managed",
|
||||
"monitor_capable": True,
|
||||
}
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
# Detect Bluetooth adapters
|
||||
if platform.system() == "Linux":
|
||||
try:
|
||||
result = subprocess.run(["hciconfig"], capture_output=True, text=True, timeout=5)
|
||||
blocks = re.split(r"(?=^hci\d+:)", result.stdout, flags=re.MULTILINE)
|
||||
for block in blocks:
|
||||
if block.strip():
|
||||
first_line = block.split("\n")[0]
|
||||
match = re.match(r"(hci\d+):", first_line)
|
||||
if match:
|
||||
iface_name = match.group(1)
|
||||
is_up = "UP RUNNING" in block or "\tUP " in block
|
||||
interfaces["bt_adapters"].append(
|
||||
{
|
||||
"name": iface_name,
|
||||
"display_name": f"Bluetooth Adapter ({iface_name})",
|
||||
"type": "hci",
|
||||
"status": "up" if is_up else "down",
|
||||
}
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
# Try bluetoothctl as fallback
|
||||
try:
|
||||
result = subprocess.run(["bluetoothctl", "list"], capture_output=True, text=True, timeout=5)
|
||||
for line in result.stdout.split("\n"):
|
||||
if "Controller" in line:
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
addr = parts[1]
|
||||
name = " ".join(parts[2:]) if len(parts) > 2 else "Bluetooth"
|
||||
interfaces["bt_adapters"].append(
|
||||
{
|
||||
"name": addr,
|
||||
"display_name": f"{name} ({addr[-8:]})",
|
||||
"type": "controller",
|
||||
"status": "available",
|
||||
}
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
pass
|
||||
elif platform.system() == "Darwin":
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["system_profiler", "SPBluetoothDataType"], capture_output=True, text=True, timeout=10
|
||||
)
|
||||
bt_name = "Built-in Bluetooth"
|
||||
bt_addr = ""
|
||||
for line in result.stdout.split("\n"):
|
||||
if "Address:" in line:
|
||||
bt_addr = line.split("Address:")[1].strip()
|
||||
break
|
||||
interfaces["bt_adapters"].append(
|
||||
{
|
||||
"name": "default",
|
||||
"display_name": f"{bt_name}" + (f" ({bt_addr[-8:]})" if bt_addr else ""),
|
||||
"type": "macos",
|
||||
"status": "available",
|
||||
}
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
interfaces["bt_adapters"].append(
|
||||
{"name": "default", "display_name": "Built-in Bluetooth", "type": "macos", "status": "available"}
|
||||
)
|
||||
|
||||
return interfaces
|
||||
+18
-24
@@ -14,7 +14,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.doppler')
|
||||
logger = get_logger("intercept.doppler")
|
||||
|
||||
# Speed of light in m/s
|
||||
SPEED_OF_LIGHT = 299_792_458.0
|
||||
@@ -36,12 +36,12 @@ class DopplerInfo:
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'frequency_hz': self.frequency_hz,
|
||||
'shift_hz': round(self.shift_hz, 1),
|
||||
'range_rate_km_s': round(self.range_rate_km_s, 3),
|
||||
'elevation': round(self.elevation, 1),
|
||||
'azimuth': round(self.azimuth, 1),
|
||||
'timestamp': self.timestamp.isoformat(),
|
||||
"frequency_hz": self.frequency_hz,
|
||||
"shift_hz": round(self.shift_hz, 1),
|
||||
"range_rate_km_s": round(self.range_rate_km_s, 3),
|
||||
"elevation": round(self.elevation, 1),
|
||||
"azimuth": round(self.azimuth, 1),
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ class DopplerTracker:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
satellite_name: str = 'ISS',
|
||||
satellite_name: str = "ISS",
|
||||
tle_data: tuple[str, str, str] | None = None,
|
||||
):
|
||||
self._satellite_name = satellite_name
|
||||
@@ -105,20 +105,13 @@ class DopplerTracker:
|
||||
self._observer_lon = longitude
|
||||
self._enabled = True
|
||||
|
||||
logger.info(
|
||||
f"DopplerTracker configured for {self._satellite_name} "
|
||||
f"at ({latitude}, {longitude})"
|
||||
)
|
||||
logger.info(f"DopplerTracker configured for {self._satellite_name} at ({latitude}, {longitude})")
|
||||
return True
|
||||
|
||||
def update_tle(self, tle_data: tuple[str, str, str]) -> bool:
|
||||
"""Update TLE data and re-configure if already enabled."""
|
||||
self._tle_data = tle_data
|
||||
if (
|
||||
self._enabled
|
||||
and self._observer_lat is not None
|
||||
and self._observer_lon is not None
|
||||
):
|
||||
if self._enabled and self._observer_lat is not None and self._observer_lon is not None:
|
||||
return self.configure(self._observer_lat, self._observer_lon)
|
||||
return True
|
||||
|
||||
@@ -177,19 +170,20 @@ class DopplerTracker:
|
||||
if self._tle_data:
|
||||
return self._tle_data
|
||||
|
||||
# Try the live TLE cache maintained by routes/satellite.py
|
||||
# Try the unified TLE store
|
||||
try:
|
||||
from routes.satellite import _tle_cache # type: ignore[import]
|
||||
if _tle_cache:
|
||||
tle = _tle_cache.get(self._satellite_name)
|
||||
if tle:
|
||||
return tle
|
||||
except (ImportError, AttributeError):
|
||||
from utils import tle_store
|
||||
|
||||
tle = tle_store.get_tle(self._satellite_name)
|
||||
if tle:
|
||||
return tle
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fall back to static bundled data
|
||||
try:
|
||||
from data.satellites import TLE_SATELLITES
|
||||
|
||||
return TLE_SATELLITES.get(self._satellite_name)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
+202
-169
@@ -29,14 +29,15 @@ from typing import Any, Callable
|
||||
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.ground_station.scheduler')
|
||||
logger = get_logger("intercept.ground_station.scheduler")
|
||||
|
||||
# Env-configurable Doppler retune threshold (Hz)
|
||||
try:
|
||||
from config import GS_DOPPLER_THRESHOLD_HZ # type: ignore[import]
|
||||
except (ImportError, AttributeError):
|
||||
import os
|
||||
GS_DOPPLER_THRESHOLD_HZ = int(os.environ.get('INTERCEPT_GS_DOPPLER_THRESHOLD_HZ', 500))
|
||||
|
||||
GS_DOPPLER_THRESHOLD_HZ = int(os.environ.get("INTERCEPT_GS_DOPPLER_THRESHOLD_HZ", 500))
|
||||
|
||||
DOPPLER_INTERVAL_SECONDS = 5
|
||||
SCHEDULE_REFRESH_MINUTES = 30
|
||||
@@ -47,6 +48,7 @@ CAPTURE_BUFFER_SECONDS = 30
|
||||
# Scheduled observation (state machine)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ScheduledObservation:
|
||||
"""A single scheduled pass for a profile."""
|
||||
|
||||
@@ -64,7 +66,7 @@ class ScheduledObservation:
|
||||
self.aos_iso = aos_iso
|
||||
self.los_iso = los_iso
|
||||
self.max_el = max_el
|
||||
self.status: str = 'scheduled'
|
||||
self.status: str = "scheduled"
|
||||
self._start_timer: threading.Timer | None = None
|
||||
self._stop_timer: threading.Timer | None = None
|
||||
|
||||
@@ -78,13 +80,13 @@ class ScheduledObservation:
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
'id': self.id,
|
||||
'norad_id': self.profile_norad_id,
|
||||
'satellite': self.satellite_name,
|
||||
'aos': self.aos_iso,
|
||||
'los': self.los_iso,
|
||||
'max_el': self.max_el,
|
||||
'status': self.status,
|
||||
"id": self.id,
|
||||
"norad_id": self.profile_norad_id,
|
||||
"satellite": self.satellite_name,
|
||||
"aos": self.aos_iso,
|
||||
"los": self.los_iso,
|
||||
"max_el": self.max_el,
|
||||
"status": self.status,
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +94,7 @@ class ScheduledObservation:
|
||||
# Scheduler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GroundStationScheduler:
|
||||
"""Automated ground station observation scheduler."""
|
||||
|
||||
@@ -104,11 +107,11 @@ class GroundStationScheduler:
|
||||
|
||||
# Active capture state
|
||||
self._active_obs: ScheduledObservation | None = None
|
||||
self._active_iq_bus = None # IQBus instance
|
||||
self._active_iq_bus = None # IQBus instance
|
||||
self._active_waterfall_consumer = None
|
||||
self._doppler_thread: threading.Thread | None = None
|
||||
self._doppler_stop = threading.Event()
|
||||
self._active_profile = None # ObservationProfile
|
||||
self._active_profile = None # ObservationProfile
|
||||
self._active_doppler_tracker = None # DopplerTracker
|
||||
|
||||
# Shared waterfall queue (consumed by /ws/satellite_waterfall)
|
||||
@@ -118,15 +121,13 @@ class GroundStationScheduler:
|
||||
self._lat: float = 0.0
|
||||
self._lon: float = 0.0
|
||||
self._device: int = 0
|
||||
self._sdr_type: str = 'rtlsdr'
|
||||
self._sdr_type: str = "rtlsdr"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public control API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_event_callback(
|
||||
self, callback: Callable[[dict[str, Any]], None]
|
||||
) -> None:
|
||||
def set_event_callback(self, callback: Callable[[dict[str, Any]], None]) -> None:
|
||||
self._event_callback = callback
|
||||
|
||||
def enable(
|
||||
@@ -134,7 +135,7 @@ class GroundStationScheduler:
|
||||
lat: float,
|
||||
lon: float,
|
||||
device: int = 0,
|
||||
sdr_type: str = 'rtlsdr',
|
||||
sdr_type: str = "rtlsdr",
|
||||
) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
self._lat = lat
|
||||
@@ -157,8 +158,8 @@ class GroundStationScheduler:
|
||||
if obs._stop_timer:
|
||||
obs._stop_timer.cancel()
|
||||
self._observations.clear()
|
||||
self._stop_active_capture(reason='scheduler_disabled')
|
||||
return {'status': 'disabled'}
|
||||
self._stop_active_capture(reason="scheduler_disabled")
|
||||
return {"status": "disabled"}
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
@@ -168,17 +169,14 @@ class GroundStationScheduler:
|
||||
with self._lock:
|
||||
active = self._active_obs.to_dict() if self._active_obs else None
|
||||
return {
|
||||
'enabled': self._enabled,
|
||||
'observer': {'latitude': self._lat, 'longitude': self._lon},
|
||||
'device': self._device,
|
||||
'sdr_type': self._sdr_type,
|
||||
'scheduled_count': sum(
|
||||
1 for o in self._observations if o.status == 'scheduled'
|
||||
),
|
||||
'total_observations': len(self._observations),
|
||||
'active_observation': active,
|
||||
'waterfall_active': self._active_iq_bus is not None
|
||||
and self._active_iq_bus.running,
|
||||
"enabled": self._enabled,
|
||||
"observer": {"latitude": self._lat, "longitude": self._lon},
|
||||
"device": self._device,
|
||||
"sdr_type": self._sdr_type,
|
||||
"scheduled_count": sum(1 for o in self._observations if o.status == "scheduled"),
|
||||
"total_observations": len(self._observations),
|
||||
"active_observation": active,
|
||||
"waterfall_active": self._active_iq_bus is not None and self._active_iq_bus.running,
|
||||
}
|
||||
|
||||
def get_scheduled_observations(self) -> list[dict[str, Any]]:
|
||||
@@ -188,9 +186,10 @@ class GroundStationScheduler:
|
||||
def trigger_manual(self, norad_id: int) -> tuple[bool, str]:
|
||||
"""Immediately start a manual observation for the given NORAD ID."""
|
||||
from utils.ground_station.observation_profile import get_profile
|
||||
|
||||
profile = get_profile(norad_id)
|
||||
if not profile:
|
||||
return False, f'No observation profile for NORAD {norad_id}'
|
||||
return False, f"No observation profile for NORAD {norad_id}"
|
||||
obs = ScheduledObservation(
|
||||
profile_norad_id=norad_id,
|
||||
satellite_name=profile.name,
|
||||
@@ -199,11 +198,11 @@ class GroundStationScheduler:
|
||||
max_el=90.0,
|
||||
)
|
||||
self._execute_observation(obs)
|
||||
return True, 'Manual observation started'
|
||||
return True, "Manual observation started"
|
||||
|
||||
def stop_active(self) -> dict[str, Any]:
|
||||
"""Stop the currently running observation."""
|
||||
self._stop_active_capture(reason='manual_stop')
|
||||
self._stop_active_capture(reason="manual_stop")
|
||||
return self.get_status()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -232,13 +231,13 @@ class GroundStationScheduler:
|
||||
with self._lock:
|
||||
# Cancel existing scheduled timers (keep active/complete)
|
||||
for obs in self._observations:
|
||||
if obs.status == 'scheduled':
|
||||
if obs.status == "scheduled":
|
||||
if obs._start_timer:
|
||||
obs._start_timer.cancel()
|
||||
if obs._stop_timer:
|
||||
obs._stop_timer.cancel()
|
||||
|
||||
history = [o for o in self._observations if o.status in ('complete', 'capturing', 'failed')]
|
||||
history = [o for o in self._observations if o.status in ("complete", "capturing", "failed")]
|
||||
self._observations = history
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -254,14 +253,12 @@ class GroundStationScheduler:
|
||||
continue
|
||||
|
||||
delay = max(0.0, (capture_start - now).total_seconds())
|
||||
obs._start_timer = threading.Timer(
|
||||
delay, self._execute_observation, args=[obs]
|
||||
)
|
||||
obs._start_timer = threading.Timer(delay, self._execute_observation, args=[obs])
|
||||
obs._start_timer.daemon = True
|
||||
obs._start_timer.start()
|
||||
self._observations.append(obs)
|
||||
|
||||
scheduled = sum(1 for o in self._observations if o.status == 'scheduled')
|
||||
scheduled = sum(1 for o in self._observations if o.status == "scheduled")
|
||||
logger.info(f"Ground station scheduler refreshed: {scheduled} observations scheduled")
|
||||
|
||||
self._arm_refresh_timer()
|
||||
@@ -271,15 +268,11 @@ class GroundStationScheduler:
|
||||
self._refresh_timer.cancel()
|
||||
if not self._enabled:
|
||||
return
|
||||
self._refresh_timer = threading.Timer(
|
||||
SCHEDULE_REFRESH_MINUTES * 60, self._refresh_schedule
|
||||
)
|
||||
self._refresh_timer = threading.Timer(SCHEDULE_REFRESH_MINUTES * 60, self._refresh_schedule)
|
||||
self._refresh_timer.daemon = True
|
||||
self._refresh_timer.start()
|
||||
|
||||
def _predict_passes_for_profiles(
|
||||
self, profiles: list
|
||||
) -> list[ScheduledObservation]:
|
||||
def _predict_passes_for_profiles(self, profiles: list) -> list[ScheduledObservation]:
|
||||
"""Predict passes for each profile and return ScheduledObservation list."""
|
||||
from skyfield.api import load, wgs84
|
||||
|
||||
@@ -289,11 +282,13 @@ class GroundStationScheduler:
|
||||
ts = load.timescale(builtin=True)
|
||||
except Exception:
|
||||
from skyfield.api import load as _load
|
||||
|
||||
ts = _load.timescale(builtin=True)
|
||||
|
||||
observer = wgs84.latlon(self._lat, self._lon)
|
||||
now = datetime.now(timezone.utc)
|
||||
import datetime as _dt
|
||||
|
||||
t0 = ts.utc(now)
|
||||
t1 = ts.utc(now + _dt.timedelta(hours=24))
|
||||
|
||||
@@ -302,9 +297,7 @@ class GroundStationScheduler:
|
||||
for profile in profiles:
|
||||
tle = _find_tle_by_norad(profile.norad_id)
|
||||
if tle is None:
|
||||
logger.warning(
|
||||
f"No TLE for NORAD {profile.norad_id} ({profile.name}) — skipping"
|
||||
)
|
||||
logger.warning(f"No TLE for NORAD {profile.norad_id} ({profile.name}) — skipping")
|
||||
continue
|
||||
try:
|
||||
passes = _predict_passes(
|
||||
@@ -325,9 +318,9 @@ class GroundStationScheduler:
|
||||
obs = ScheduledObservation(
|
||||
profile_norad_id=profile.norad_id,
|
||||
satellite_name=profile.name,
|
||||
aos_iso=p.get('startTimeISO', ''),
|
||||
los_iso=p.get('endTimeISO', ''),
|
||||
max_el=float(p.get('maxEl', 0.0)),
|
||||
aos_iso=p.get("startTimeISO", ""),
|
||||
los_iso=p.get("endTimeISO", ""),
|
||||
max_el=float(p.get("maxEl", 0.0)),
|
||||
)
|
||||
observations.append(obs)
|
||||
|
||||
@@ -341,25 +334,27 @@ class GroundStationScheduler:
|
||||
"""Called at AOS (+ buffer) to start IQ capture."""
|
||||
if not self._enabled:
|
||||
return
|
||||
if obs.status == 'scheduled':
|
||||
obs.status = 'capturing'
|
||||
if obs.status == "scheduled":
|
||||
obs.status = "capturing"
|
||||
else:
|
||||
return # already cancelled / complete
|
||||
|
||||
from utils.ground_station.observation_profile import get_profile
|
||||
|
||||
profile = get_profile(obs.profile_norad_id)
|
||||
if not profile or not profile.enabled:
|
||||
obs.status = 'failed'
|
||||
obs.status = "failed"
|
||||
return
|
||||
|
||||
# Claim SDR device
|
||||
try:
|
||||
import app as _app
|
||||
err = _app.claim_sdr_device(self._device, 'ground_station_iq_bus', self._sdr_type)
|
||||
|
||||
err = _app.claim_sdr_device(self._device, "ground_station_iq_bus", self._sdr_type)
|
||||
if err:
|
||||
logger.warning(f"Ground station: SDR busy — skipping {obs.satellite_name}: {err}")
|
||||
obs.status = 'failed'
|
||||
self._emit_event({'type': 'observation_skipped', 'observation': obs.to_dict(), 'reason': 'device_busy'})
|
||||
obs.status = "failed"
|
||||
self._emit_event({"type": "observation_skipped", "observation": obs.to_dict(), "reason": "device_busy"})
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -369,6 +364,7 @@ class GroundStationScheduler:
|
||||
|
||||
# Build IQ bus
|
||||
from utils.ground_station.iq_bus import IQBus
|
||||
|
||||
bus = IQBus(
|
||||
center_mhz=profile.frequency_mhz,
|
||||
sample_rate=profile.iq_sample_rate,
|
||||
@@ -379,6 +375,7 @@ class GroundStationScheduler:
|
||||
|
||||
# Attach waterfall consumer (always)
|
||||
from utils.ground_station.consumers.waterfall import WaterfallConsumer
|
||||
|
||||
wf_consumer = WaterfallConsumer(output_queue=self.waterfall_queue)
|
||||
bus.add_consumer(wf_consumer)
|
||||
|
||||
@@ -393,13 +390,14 @@ class GroundStationScheduler:
|
||||
ok, err_msg = bus.start()
|
||||
if not ok:
|
||||
logger.error(f"Ground station: failed to start IQBus for {obs.satellite_name}: {err_msg}")
|
||||
obs.status = 'failed'
|
||||
obs.status = "failed"
|
||||
try:
|
||||
import app as _app
|
||||
|
||||
_app.release_sdr_device(self._device, self._sdr_type)
|
||||
except ImportError:
|
||||
pass
|
||||
self._emit_event({'type': 'observation_failed', 'observation': obs.to_dict(), 'reason': err_msg})
|
||||
self._emit_event({"type": "observation_failed", "observation": obs.to_dict(), "reason": err_msg})
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
@@ -410,13 +408,15 @@ class GroundStationScheduler:
|
||||
|
||||
# Emit iq_bus_started SSE event (used by Phase 5 waterfall)
|
||||
span_mhz = profile.iq_sample_rate / 1e6
|
||||
self._emit_event({
|
||||
'type': 'iq_bus_started',
|
||||
'observation': obs.to_dict(),
|
||||
'center_mhz': profile.frequency_mhz,
|
||||
'span_mhz': span_mhz,
|
||||
})
|
||||
self._emit_event({'type': 'observation_started', 'observation': obs.to_dict()})
|
||||
self._emit_event(
|
||||
{
|
||||
"type": "iq_bus_started",
|
||||
"observation": obs.to_dict(),
|
||||
"center_mhz": profile.frequency_mhz,
|
||||
"span_mhz": span_mhz,
|
||||
}
|
||||
)
|
||||
self._emit_event({"type": "observation_started", "observation": obs.to_dict()})
|
||||
logger.info(f"Ground station: observation started for {obs.satellite_name} (NORAD {obs.profile_norad_id})")
|
||||
|
||||
# Start Doppler correction thread
|
||||
@@ -426,15 +426,13 @@ class GroundStationScheduler:
|
||||
now = datetime.now(timezone.utc)
|
||||
stop_delay = (obs.los_dt + timedelta(seconds=CAPTURE_BUFFER_SECONDS) - now).total_seconds()
|
||||
if stop_delay > 0:
|
||||
obs._stop_timer = threading.Timer(
|
||||
stop_delay, self._stop_active_capture, kwargs={'reason': 'los'}
|
||||
)
|
||||
obs._stop_timer = threading.Timer(stop_delay, self._stop_active_capture, kwargs={"reason": "los"})
|
||||
obs._stop_timer.daemon = True
|
||||
obs._stop_timer.start()
|
||||
else:
|
||||
self._stop_active_capture(reason='los_immediate')
|
||||
self._stop_active_capture(reason="los_immediate")
|
||||
|
||||
def _stop_active_capture(self, *, reason: str = 'manual') -> None:
|
||||
def _stop_active_capture(self, *, reason: str = "manual") -> None:
|
||||
"""Stop the currently active capture and release the SDR device."""
|
||||
with self._lock:
|
||||
bus = self._active_iq_bus
|
||||
@@ -451,17 +449,20 @@ class GroundStationScheduler:
|
||||
bus.stop()
|
||||
|
||||
if obs:
|
||||
obs.status = 'complete'
|
||||
_update_observation_status(obs, 'complete')
|
||||
self._emit_event({
|
||||
'type': 'observation_complete',
|
||||
'observation': obs.to_dict(),
|
||||
'reason': reason,
|
||||
})
|
||||
self._emit_event({'type': 'iq_bus_stopped', 'observation': obs.to_dict()})
|
||||
obs.status = "complete"
|
||||
_update_observation_status(obs, "complete")
|
||||
self._emit_event(
|
||||
{
|
||||
"type": "observation_complete",
|
||||
"observation": obs.to_dict(),
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
self._emit_event({"type": "iq_bus_stopped", "observation": obs.to_dict()})
|
||||
|
||||
try:
|
||||
import app as _app
|
||||
|
||||
_app.release_sdr_device(self._device, self._sdr_type)
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -478,47 +479,53 @@ class GroundStationScheduler:
|
||||
|
||||
tasks = _get_profile_tasks(profile)
|
||||
|
||||
if 'telemetry_ax25' in tasks:
|
||||
if shutil.which('direwolf'):
|
||||
if "telemetry_ax25" in tasks:
|
||||
if shutil.which("direwolf"):
|
||||
from utils.ground_station.consumers.fm_demod import FMDemodConsumer
|
||||
|
||||
consumer = FMDemodConsumer(
|
||||
decoder_cmd=[
|
||||
'direwolf', '-r', '48000', '-n', '1', '-b', '16', '-',
|
||||
"direwolf",
|
||||
"-r",
|
||||
"48000",
|
||||
"-n",
|
||||
"1",
|
||||
"-b",
|
||||
"16",
|
||||
"-",
|
||||
],
|
||||
modulation='fm',
|
||||
on_decoded=lambda line: self._on_packet_decoded(
|
||||
line, obs_db_id, obs, source='direwolf'
|
||||
),
|
||||
modulation="fm",
|
||||
on_decoded=lambda line: self._on_packet_decoded(line, obs_db_id, obs, source="direwolf"),
|
||||
)
|
||||
bus.add_consumer(consumer)
|
||||
logger.info("Ground station: attached direwolf AX.25 decoder")
|
||||
else:
|
||||
logger.warning("direwolf not found — AX.25 decoding disabled")
|
||||
|
||||
if 'telemetry_gmsk' in tasks:
|
||||
if shutil.which('multimon-ng'):
|
||||
if "telemetry_gmsk" in tasks:
|
||||
if shutil.which("multimon-ng"):
|
||||
from utils.ground_station.consumers.fm_demod import FMDemodConsumer
|
||||
|
||||
consumer = FMDemodConsumer(
|
||||
decoder_cmd=['multimon-ng', '-t', 'raw', '-a', 'GMSK', '-'],
|
||||
modulation='fm',
|
||||
on_decoded=lambda line: self._on_packet_decoded(
|
||||
line, obs_db_id, obs, source='multimon-ng'
|
||||
),
|
||||
decoder_cmd=["multimon-ng", "-t", "raw", "-a", "GMSK", "-"],
|
||||
modulation="fm",
|
||||
on_decoded=lambda line: self._on_packet_decoded(line, obs_db_id, obs, source="multimon-ng"),
|
||||
)
|
||||
bus.add_consumer(consumer)
|
||||
logger.info("Ground station: attached multimon-ng GMSK decoder")
|
||||
else:
|
||||
logger.warning("multimon-ng not found — GMSK decoding disabled")
|
||||
|
||||
if 'telemetry_bpsk' in tasks:
|
||||
if "telemetry_bpsk" in tasks:
|
||||
from utils.ground_station.consumers.gr_satellites import GrSatConsumer
|
||||
|
||||
consumer = GrSatConsumer(
|
||||
satellite_name=profile.name,
|
||||
on_decoded=lambda pkt: self._on_packet_decoded(
|
||||
pkt,
|
||||
obs_db_id,
|
||||
obs,
|
||||
source='gr_satellites',
|
||||
source="gr_satellites",
|
||||
),
|
||||
)
|
||||
bus.add_consumer(consumer)
|
||||
@@ -539,15 +546,18 @@ class GroundStationScheduler:
|
||||
|
||||
def _on_recording_complete(meta_path, data_path):
|
||||
_insert_recording_record(obs_db_id, meta_path, data_path, profile)
|
||||
self._emit_event({
|
||||
'type': 'recording_complete',
|
||||
'norad_id': profile.norad_id,
|
||||
'data_path': str(data_path),
|
||||
'meta_path': str(meta_path),
|
||||
})
|
||||
if 'weather_meteor_lrpt' in _get_profile_tasks(profile):
|
||||
self._emit_event(
|
||||
{
|
||||
"type": "recording_complete",
|
||||
"norad_id": profile.norad_id,
|
||||
"data_path": str(data_path),
|
||||
"meta_path": str(meta_path),
|
||||
}
|
||||
)
|
||||
if "weather_meteor_lrpt" in _get_profile_tasks(profile):
|
||||
try:
|
||||
from utils.ground_station.meteor_backend import launch_meteor_decode
|
||||
|
||||
launch_meteor_decode(
|
||||
obs_db_id=obs_db_id,
|
||||
norad_id=profile.norad_id,
|
||||
@@ -559,13 +569,15 @@ class GroundStationScheduler:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to launch Meteor decode backend: {e}")
|
||||
self._emit_event({
|
||||
'type': 'weather_decode_failed',
|
||||
'norad_id': profile.norad_id,
|
||||
'satellite': profile.name,
|
||||
'backend': 'meteor_lrpt',
|
||||
'message': str(e),
|
||||
})
|
||||
self._emit_event(
|
||||
{
|
||||
"type": "weather_decode_failed",
|
||||
"norad_id": profile.norad_id,
|
||||
"satellite": profile.name,
|
||||
"backend": "meteor_lrpt",
|
||||
"message": str(e),
|
||||
}
|
||||
)
|
||||
|
||||
consumer = SigMFConsumer(metadata=meta, on_complete=_on_recording_complete)
|
||||
bus.add_consumer(consumer)
|
||||
@@ -597,7 +609,7 @@ class GroundStationScheduler:
|
||||
target=self._doppler_loop,
|
||||
args=[profile, tracker],
|
||||
daemon=True,
|
||||
name='gs-doppler',
|
||||
name="gs-doppler",
|
||||
)
|
||||
t.start()
|
||||
self._doppler_thread = t
|
||||
@@ -624,15 +636,18 @@ class GroundStationScheduler:
|
||||
f"{corrected_mhz:.6f} MHz (el={info.elevation:.1f}°)"
|
||||
)
|
||||
bus.retune(corrected_mhz)
|
||||
self._emit_event({
|
||||
'type': 'doppler_update',
|
||||
'norad_id': profile.norad_id,
|
||||
**info.to_dict(),
|
||||
})
|
||||
self._emit_event(
|
||||
{
|
||||
"type": "doppler_update",
|
||||
"norad_id": profile.norad_id,
|
||||
**info.to_dict(),
|
||||
}
|
||||
)
|
||||
|
||||
# Rotator control (Phase 6)
|
||||
try:
|
||||
from utils.rotator import get_rotator
|
||||
|
||||
rotator = get_rotator()
|
||||
if rotator.enabled:
|
||||
rotator.point_to(info.azimuth, info.elevation)
|
||||
@@ -651,20 +666,22 @@ class GroundStationScheduler:
|
||||
obs_db_id: int | None,
|
||||
obs: ScheduledObservation,
|
||||
*,
|
||||
source: str = 'decoder',
|
||||
source: str = "decoder",
|
||||
) -> None:
|
||||
"""Handle a decoded packet payload from a decoder consumer."""
|
||||
if payload is None or payload == '':
|
||||
if payload is None or payload == "":
|
||||
return
|
||||
|
||||
packet_event = _build_packet_event(payload, source)
|
||||
_insert_event_record(obs_db_id, 'packet', json.dumps(packet_event))
|
||||
self._emit_event({
|
||||
'type': 'packet_decoded',
|
||||
'norad_id': obs.profile_norad_id,
|
||||
'satellite': obs.satellite_name,
|
||||
**packet_event,
|
||||
})
|
||||
_insert_event_record(obs_db_id, "packet", json.dumps(packet_event))
|
||||
self._emit_event(
|
||||
{
|
||||
"type": "packet_decoded",
|
||||
"norad_id": obs.profile_norad_id,
|
||||
"satellite": obs.satellite_name,
|
||||
**packet_event,
|
||||
}
|
||||
)
|
||||
|
||||
def _emit_event(self, event: dict[str, Any]) -> None:
|
||||
if self._event_callback:
|
||||
@@ -684,20 +701,24 @@ def _insert_observation_record(obs: ScheduledObservation, profile) -> int | None
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from utils.database import get_db
|
||||
|
||||
with get_db() as conn:
|
||||
cur = conn.execute('''
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO ground_station_observations
|
||||
(profile_id, norad_id, satellite, aos_time, los_time, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
profile.id,
|
||||
obs.profile_norad_id,
|
||||
obs.satellite_name,
|
||||
obs.aos_iso,
|
||||
obs.los_iso,
|
||||
'capturing',
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
))
|
||||
""",
|
||||
(
|
||||
profile.id,
|
||||
obs.profile_norad_id,
|
||||
obs.satellite_name,
|
||||
obs.aos_iso,
|
||||
obs.los_iso,
|
||||
"capturing",
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
),
|
||||
)
|
||||
return cur.lastrowid
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to insert observation record: {e}")
|
||||
@@ -707,10 +728,11 @@ def _insert_observation_record(obs: ScheduledObservation, profile) -> int | None
|
||||
def _update_observation_status(obs: ScheduledObservation, status: str) -> None:
|
||||
try:
|
||||
from utils.database import get_db
|
||||
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
'UPDATE ground_station_observations SET status=? WHERE norad_id=? AND status=?',
|
||||
(status, obs.profile_norad_id, 'capturing'),
|
||||
"UPDATE ground_station_observations SET status=? WHERE norad_id=? AND status=?",
|
||||
(status, obs.profile_norad_id, "capturing"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to update observation status: {e}")
|
||||
@@ -723,17 +745,21 @@ def _insert_event_record(obs_db_id: int | None, event_type: str, payload: str) -
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from utils.database import get_db
|
||||
|
||||
with get_db() as conn:
|
||||
conn.execute('''
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO ground_station_events (observation_id, event_type, payload_json, timestamp)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (obs_db_id, event_type, payload, datetime.now(timezone.utc).isoformat()))
|
||||
""",
|
||||
(obs_db_id, event_type, payload, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to insert event record: {e}")
|
||||
|
||||
|
||||
def _get_profile_tasks(profile) -> list[str]:
|
||||
get_tasks = getattr(profile, 'get_tasks', None)
|
||||
get_tasks = getattr(profile, "get_tasks", None)
|
||||
if callable(get_tasks):
|
||||
return get_tasks()
|
||||
return []
|
||||
@@ -741,26 +767,26 @@ def _get_profile_tasks(profile) -> list[str]:
|
||||
|
||||
def _profile_requires_iq_recording(profile) -> bool:
|
||||
tasks = _get_profile_tasks(profile)
|
||||
return bool(getattr(profile, 'record_iq', False) or 'record_iq' in tasks or 'weather_meteor_lrpt' in tasks)
|
||||
return bool(getattr(profile, "record_iq", False) or "record_iq" in tasks or "weather_meteor_lrpt" in tasks)
|
||||
|
||||
|
||||
def _build_packet_event(payload, source: str) -> dict[str, Any]:
|
||||
event: dict[str, Any] = {
|
||||
'source': source,
|
||||
'data': payload if isinstance(payload, str) else json.dumps(payload),
|
||||
'parsed': None,
|
||||
"source": source,
|
||||
"data": payload if isinstance(payload, str) else json.dumps(payload),
|
||||
"parsed": None,
|
||||
}
|
||||
|
||||
if isinstance(payload, dict):
|
||||
event['parsed'] = payload
|
||||
event['protocol'] = payload.get('protocol') or payload.get('type') or source
|
||||
event["parsed"] = payload
|
||||
event["protocol"] = payload.get("protocol") or payload.get("type") or source
|
||||
return event
|
||||
|
||||
text = str(payload).strip()
|
||||
event['data'] = text
|
||||
event["data"] = text
|
||||
|
||||
parsed = None
|
||||
if source == 'gr_satellites':
|
||||
if source == "gr_satellites":
|
||||
try:
|
||||
candidate = json.loads(text)
|
||||
if isinstance(candidate, dict):
|
||||
@@ -774,7 +800,7 @@ def _build_packet_event(payload, source: str) -> dict[str, Any]:
|
||||
|
||||
from utils.satellite_telemetry import auto_parse
|
||||
|
||||
for token in text.replace(',', ' ').split():
|
||||
for token in text.replace(",", " ").split():
|
||||
cleaned = token.strip()
|
||||
if not cleaned or len(cleaned) < 8:
|
||||
continue
|
||||
@@ -789,9 +815,9 @@ def _build_packet_event(payload, source: str) -> dict[str, Any]:
|
||||
except Exception:
|
||||
parsed = None
|
||||
|
||||
event['parsed'] = parsed
|
||||
event["parsed"] = parsed
|
||||
if isinstance(parsed, dict):
|
||||
event['protocol'] = parsed.get('protocol') or source
|
||||
event["protocol"] = parsed.get("protocol") or source
|
||||
return event
|
||||
|
||||
|
||||
@@ -800,22 +826,26 @@ def _insert_recording_record(obs_db_id: int | None, meta_path: Path, data_path:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from utils.database import get_db
|
||||
|
||||
size = data_path.stat().st_size if data_path.exists() else 0
|
||||
with get_db() as conn:
|
||||
conn.execute('''
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO sigmf_recordings
|
||||
(observation_id, sigmf_data_path, sigmf_meta_path, size_bytes,
|
||||
sample_rate, center_freq_hz, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
obs_db_id,
|
||||
str(data_path),
|
||||
str(meta_path),
|
||||
size,
|
||||
profile.iq_sample_rate,
|
||||
int(profile.frequency_mhz * 1e6),
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
))
|
||||
""",
|
||||
(
|
||||
obs_db_id,
|
||||
str(data_path),
|
||||
str(meta_path),
|
||||
size,
|
||||
profile.iq_sample_rate,
|
||||
int(profile.frequency_mhz * 1e6),
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to insert recording record: {e}")
|
||||
|
||||
@@ -837,12 +867,12 @@ def _insert_output_record(
|
||||
|
||||
with get_db() as conn:
|
||||
cur = conn.execute(
|
||||
'''
|
||||
"""
|
||||
INSERT INTO ground_station_outputs
|
||||
(observation_id, norad_id, output_type, backend, file_path,
|
||||
preview_path, metadata_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''',
|
||||
""",
|
||||
(
|
||||
observation_id,
|
||||
norad_id,
|
||||
@@ -870,13 +900,16 @@ def _find_tle_by_norad(norad_id: int) -> tuple[str, str, str] | None:
|
||||
# Try live cache first
|
||||
sources = []
|
||||
try:
|
||||
from routes.satellite import _tle_cache # type: ignore[import]
|
||||
if _tle_cache:
|
||||
sources.append(_tle_cache)
|
||||
except (ImportError, AttributeError):
|
||||
from utils import tle_store
|
||||
|
||||
live = tle_store.all_tles()
|
||||
if live:
|
||||
sources.append(live)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from data.satellites import TLE_SATELLITES
|
||||
|
||||
sources.append(TLE_SATELLITES)
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -903,9 +936,9 @@ def _find_tle_by_norad(norad_id: int) -> tuple[str, str, str] | None:
|
||||
|
||||
|
||||
def _parse_utc_iso(value: str) -> datetime:
|
||||
text = str(value).strip().replace('+00:00Z', 'Z')
|
||||
if text.endswith('Z'):
|
||||
text = text[:-1] + '+00:00'
|
||||
text = str(value).strip().replace("+00:00Z", "Z")
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
dt = datetime.fromisoformat(text)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
+2
-13
@@ -35,21 +35,10 @@ def is_meshcore_available() -> bool:
|
||||
return HAS_MESHCORE
|
||||
|
||||
|
||||
# Try to import ContactType for repeater detection
|
||||
try:
|
||||
from meshcore import ContactType as _ContactType
|
||||
|
||||
_REPEATER_TYPE = getattr(_ContactType, "REPEATER", None)
|
||||
except Exception:
|
||||
_ContactType = None
|
||||
_REPEATER_TYPE = None
|
||||
|
||||
|
||||
def _is_repeater_contact(contact_dict: dict) -> bool:
|
||||
"""Return True if this contact is a repeater node."""
|
||||
if _REPEATER_TYPE is not None:
|
||||
return contact_dict.get("type") == _REPEATER_TYPE
|
||||
# Fallback: meshcore repeaters have type==2 by convention
|
||||
# meshcore exports no ContactType enum (checked through 2.3.7);
|
||||
# repeaters have type==2 by library convention
|
||||
return contact_dict.get("type") == 2
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Unified TLE store.
|
||||
|
||||
Single source of truth for TLE data, shared by satellite tracking,
|
||||
weather-satellite prediction, SSTV doppler, and the remote agent.
|
||||
Backed by SQLite; seeded once from the static data/satellites.py.
|
||||
|
||||
Replaces three previous stores: routes/satellite._tle_cache (which
|
||||
persisted by rewriting data/satellites.py at runtime),
|
||||
utils/weather_sat_predict._tle_cache, and the agent's own download.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger("intercept.tle_store")
|
||||
|
||||
_DB_PATH = Path(__file__).resolve().parent.parent / "instance" / "tle.db"
|
||||
_lock = threading.Lock()
|
||||
_cache: dict[str, tuple[str, str, str]] | None = None
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(_DB_PATH), check_same_thread=False, timeout=5)
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute("PRAGMA busy_timeout = 5000")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS tle_entries (
|
||||
key TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
line1 TEXT NOT NULL,
|
||||
line2 TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
_seed_if_empty(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def _seed_if_empty(conn: sqlite3.Connection) -> None:
|
||||
count = conn.execute("SELECT COUNT(*) FROM tle_entries").fetchone()[0]
|
||||
if count:
|
||||
return
|
||||
try:
|
||||
from data.satellites import TLE_SATELLITES
|
||||
except ImportError:
|
||||
logger.warning("data/satellites.py unavailable; TLE store starts empty")
|
||||
return
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO tle_entries (key, name, line1, line2) VALUES (?, ?, ?, ?)",
|
||||
[(key, name, l1, l2) for key, (name, l1, l2) in TLE_SATELLITES.items()],
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(f"Seeded TLE store with {len(TLE_SATELLITES)} entries")
|
||||
|
||||
|
||||
def _load() -> dict[str, tuple[str, str, str]]:
|
||||
global _cache
|
||||
if _cache is None:
|
||||
with _lock:
|
||||
if _cache is None:
|
||||
conn = _connect()
|
||||
try:
|
||||
rows = conn.execute("SELECT key, name, line1, line2 FROM tle_entries").fetchall()
|
||||
# Single-statement assignment of the fully built dict —
|
||||
# the DCL pattern is only safe because readers never see
|
||||
# a partially populated cache
|
||||
_cache = {key: (name, l1, l2) for key, name, l1, l2 in rows}
|
||||
finally:
|
||||
conn.close()
|
||||
return _cache
|
||||
|
||||
|
||||
def all_tles() -> dict[str, tuple[str, str, str]]:
|
||||
"""Return all TLEs as {key: (name, line1, line2)}."""
|
||||
return dict(_load())
|
||||
|
||||
|
||||
def get_tle(key: str) -> tuple[str, str, str] | None:
|
||||
"""Return (name, line1, line2) for a satellite key, or None."""
|
||||
return _load().get(key)
|
||||
|
||||
|
||||
def update(entries: dict[str, tuple[str, str, str]]) -> None:
|
||||
"""Insert or replace TLE entries and refresh the cache."""
|
||||
if not entries:
|
||||
return
|
||||
with _lock:
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO tle_entries (key, name, line1, line2, updated_at)"
|
||||
" VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
[(key, name, l1, l2) for key, (name, l1, l2) in entries.items()],
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
global _cache
|
||||
_cache = None # rebuilt on next read
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Clear the in-memory cache so the next read hits the database.
|
||||
|
||||
Seeding only runs when the table is empty, so a reset never overwrites
|
||||
entries written by a test.
|
||||
"""
|
||||
global _cache
|
||||
_cache = None
|
||||
+421
-366
File diff suppressed because it is too large
Load Diff
@@ -17,11 +17,7 @@ from data.satellites import TLE_SATELLITES
|
||||
from utils.logging import get_logger
|
||||
from utils.weather_sat import WEATHER_SATELLITES
|
||||
|
||||
logger = get_logger('intercept.weather_sat_predict')
|
||||
|
||||
# Live TLE cache — populated by routes/satellite.py at startup.
|
||||
# Module-level so tests can patch it with patch('utils.weather_sat_predict._tle_cache', ...).
|
||||
_tle_cache: dict = {}
|
||||
logger = get_logger("intercept.weather_sat_predict")
|
||||
|
||||
|
||||
def _format_utc_iso(dt: datetime.datetime) -> str:
|
||||
@@ -32,13 +28,16 @@ def _format_utc_iso(dt: datetime.datetime) -> str:
|
||||
"""
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(datetime.timezone.utc).replace(tzinfo=None)
|
||||
return dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _get_tle_source() -> dict:
|
||||
"""Return the best available TLE source (live cache preferred over static data)."""
|
||||
if _tle_cache:
|
||||
return _tle_cache
|
||||
"""Return the best available TLE source (unified store, static fallback)."""
|
||||
from utils import tle_store
|
||||
|
||||
tles = tle_store.all_tles()
|
||||
if tles:
|
||||
return tles
|
||||
return TLE_SATELLITES
|
||||
|
||||
|
||||
@@ -79,11 +78,11 @@ def predict_passes(
|
||||
all_passes: list[dict[str, Any]] = []
|
||||
|
||||
for sat_key, sat_info in WEATHER_SATELLITES.items():
|
||||
if not sat_info['active']:
|
||||
if not sat_info["active"]:
|
||||
continue
|
||||
|
||||
try:
|
||||
tle_data = tle_source.get(sat_info['tle_key'])
|
||||
tle_data = tle_source.get(sat_info["tle_key"])
|
||||
if not tle_data:
|
||||
continue
|
||||
|
||||
@@ -104,18 +103,25 @@ def predict_passes(
|
||||
rise_t = t
|
||||
elif rise_t is not None:
|
||||
_process_pass(
|
||||
sat_key, sat_info, satellite, diff, ts,
|
||||
rise_t, t, min_elevation,
|
||||
include_trajectory, include_ground_track,
|
||||
sat_key,
|
||||
sat_info,
|
||||
satellite,
|
||||
diff,
|
||||
ts,
|
||||
rise_t,
|
||||
t,
|
||||
min_elevation,
|
||||
include_trajectory,
|
||||
include_ground_track,
|
||||
all_passes,
|
||||
)
|
||||
rise_t = None
|
||||
|
||||
except Exception as exc:
|
||||
logger.debug('Error predicting passes for %s: %s', sat_key, exc)
|
||||
logger.debug("Error predicting passes for %s: %s", sat_key, exc)
|
||||
continue
|
||||
|
||||
all_passes.sort(key=lambda p: p['startTimeISO'])
|
||||
all_passes.sort(key=lambda p: p["startTimeISO"])
|
||||
return all_passes
|
||||
|
||||
|
||||
@@ -155,7 +161,7 @@ def _process_pass(
|
||||
max_el = el
|
||||
max_el_az = az_deg
|
||||
if include_trajectory:
|
||||
traj_points.append({'az': round(az_deg, 1), 'el': round(max(0.0, el), 1)})
|
||||
traj_points.append({"az": round(az_deg, 1), "el": round(max(0.0, el), 1)})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -181,32 +187,28 @@ def _process_pass(
|
||||
pass_id = f"{sat_key}_{aos_iso}"
|
||||
|
||||
pass_dict: dict[str, Any] = {
|
||||
'id': pass_id,
|
||||
'satellite': sat_key,
|
||||
'name': sat_info['name'],
|
||||
'frequency': sat_info['frequency'],
|
||||
'mode': sat_info['mode'],
|
||||
'startTime': rise_dt.strftime('%Y-%m-%d %H:%M UTC'),
|
||||
'startTimeISO': aos_iso,
|
||||
'endTimeISO': _format_utc_iso(set_dt),
|
||||
'maxEl': round(max_el, 1),
|
||||
'maxElAz': round(max_el_az, 1),
|
||||
'riseAz': round(rise_az, 1),
|
||||
'setAz': round(set_az, 1),
|
||||
'duration': round(duration_secs, 1),
|
||||
'quality': (
|
||||
'excellent' if max_el >= 60
|
||||
else 'good' if max_el >= 30
|
||||
else 'fair'
|
||||
),
|
||||
"id": pass_id,
|
||||
"satellite": sat_key,
|
||||
"name": sat_info["name"],
|
||||
"frequency": sat_info["frequency"],
|
||||
"mode": sat_info["mode"],
|
||||
"startTime": rise_dt.strftime("%Y-%m-%d %H:%M UTC"),
|
||||
"startTimeISO": aos_iso,
|
||||
"endTimeISO": _format_utc_iso(set_dt),
|
||||
"maxEl": round(max_el, 1),
|
||||
"maxElAz": round(max_el_az, 1),
|
||||
"riseAz": round(rise_az, 1),
|
||||
"setAz": round(set_az, 1),
|
||||
"duration": round(duration_secs, 1),
|
||||
"quality": ("excellent" if max_el >= 60 else "good" if max_el >= 30 else "fair"),
|
||||
# Backwards-compatible aliases used by weather_sat_scheduler and the frontend
|
||||
'aosAz': round(rise_az, 1),
|
||||
'losAz': round(set_az, 1),
|
||||
'tcaAz': round(max_el_az, 1),
|
||||
"aosAz": round(rise_az, 1),
|
||||
"losAz": round(set_az, 1),
|
||||
"tcaAz": round(max_el_az, 1),
|
||||
}
|
||||
|
||||
if include_trajectory:
|
||||
pass_dict['trajectory'] = traj_points
|
||||
pass_dict["trajectory"] = traj_points
|
||||
|
||||
if include_ground_track:
|
||||
ground_track = []
|
||||
@@ -217,12 +219,14 @@ def _process_pass(
|
||||
try:
|
||||
geocentric = satellite.at(t_pt)
|
||||
subpoint = wgs84.subpoint(geocentric)
|
||||
ground_track.append({
|
||||
'lat': round(float(subpoint.latitude.degrees), 4),
|
||||
'lon': round(float(subpoint.longitude.degrees), 4),
|
||||
})
|
||||
ground_track.append(
|
||||
{
|
||||
"lat": round(float(subpoint.latitude.degrees), 4),
|
||||
"lon": round(float(subpoint.longitude.degrees), 4),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
pass_dict['groundTrack'] = ground_track
|
||||
pass_dict["groundTrack"] = ground_track
|
||||
|
||||
all_passes.append(pass_dict)
|
||||
|
||||
Reference in New Issue
Block a user