Compare commits

...

50 Commits

Author SHA1 Message Date
James Smith 386b95a25d fix: skip vcgencmd throttle probe when binary is absent
The system metrics collector daemon thread ran vcgencmd via subprocess
every 3s even on non-Pi hosts, where it always failed — and leaked
Popen calls into any later test mocking subprocess (intermittent
test_weather_sat_decoder failure in full-suite runs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:43:34 +01:00
James Smith 753a08234e fix: tracker signature scoring — gate boost/length signals, name-only detects LOW
confidence_boost and the manufacturer-data-length signal applied without
any identifying indicator match, giving every device a phantom AirTag
baseline (a 22+ byte payload from any vendor scored 0.30 and was flagged
as an AirTag). Both now require a matched indicator, mirroring the
score>0 gating already used in _check_generic_tracker_indicators.

Name-pattern weight raised 0.15 -> 0.30 so a device advertising a known
tracker name yields a LOW-confidence detection, consistent with the
TSCM BLE scanner's name-only detection and the engine docstring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:43:20 +01:00
James Smith 276b151e9e test: repair stale assertions in bluetooth group and deauth detector
Bluetooth aggregator/api/heuristics tests updated to current behavior;
deauth detector integration test rewritten to exercise the tracker and
alert path directly instead of patching __globals__ (read-only on
Python 3.14).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:44:59 +01:00
James Smith 30450295b5 test: repair stale assertions in validation/waterfall/meshtastic/routes
Auth fixture, /listening->/receiver waterfall rename, numeric validator
returns, and float timestamp — all matching current code behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:56:02 +01:00
James Smith 47c0fcbefa fix: guard capabilities import in agent for stripped-host degradation
Matches the agent's established try/except import convention; the
agent now starts and reports empty capabilities when utils.capabilities
(or its dependency chain) is unavailable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:39:39 +01:00
James Smith 2ec5085673 fix: align pyproject meshcore pin with requirements.txt (>=2.3.0)
Caught by test_dependency_files_integrity, which had been buried in the
never-reached tail of the test suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:30:19 +01:00
James Smith 379b6a9667 docs: record UI direction — dashboards for map-heavy modes
Phase 5 decision gate of the architecture refactor plan: dedicated
dashboard pages are the pattern for map-centric modes; APRS and
Meshtastic migrations to follow under separate plans.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:46:37 +01:00
James Smith 5cff7de117 refactor: single dependency probe in capability detection; real test coverage
detect_mode_availability accepts a pre-computed dep_status so the agent
probes once; interface and fallback paths now have content-level tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:37:30 +01:00
James Smith 0588055d1f refactor: extract shared capability detection from agent
utils/capabilities.py now owns interface detection and mode
availability; the agent delegates via detect_interfaces() and
detect_mode_availability(). The agent keeps config gating and
tool_details population to preserve its result shape exactly.

The moved fallback path uses utils.dependencies.check_tool instead of
the agent's old shutil.which fallback; check_tool also searches
Homebrew paths, a strict superset (strictly better detection).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:31:54 +01:00
James Smith e14271c5ee test: mode registry consistency checks; fail fast if registry missing
Also documents the registry-driven mode integration in CLAUDE.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:11:04 +01:00
James Smith e38b8fb464 refactor: registry-driven mode init dispatch in switchMode
A failing mode init now logs instead of aborting the remainder of
switchMode (deliberate hardening; previously an exception skipped
title/visuals updates).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:03:40 +01:00
James Smith a1b1e5a77e refactor: derive sidebar toggles and destroy map from registry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:46:40 +01:00
James Smith 5d3811cc60 refactor: derive modeCatalog and modesWithVisuals from registry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:29:37 +01:00
James Smith 8813d069bc feat: introduce frontend mode registry (no behaviour change)
window.INTERCEPT_MODES mirrors the existing modeCatalog, sidebar
toggles, visuals list, destroy map, and switchMode init branches.
Derivations follow in subsequent commits; a temporary console guard
verifies registry/catalog parity at runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:16:40 +01:00
James Smith cdb5285b68 fix: reject non-canonical subpaths in agent proxy allowlist
requests/urllib3 collapse dot segments before sending, so traversal
like wifi/v2/../../x escaped the prefix allowlist. Only canonical
paths are now forwarded; regression tests included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:49:19 +01:00
James Smith 67847eb708 refactor: route agent wifi clients through generic proxy
Removes the one-off proxy_wifi_clients route and the dead getApiBase()
helper; the allowlisted passthrough now covers agent wifi traffic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:43:16 +01:00
James Smith c870f118bf feat: allowlisted generic agent proxy route
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:41:01 +01:00
James Smith a202c9dd94 refactor: agent satellite predictor reads TLEs from unified store
Removes the agent's own CelesTrak download (the source of the stray
gp.php artifact) — the store is now the single TLE source for app and
agent alike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:31:00 +01:00
James Smith 07887b7c99 test: isolate the TLE store suite-wide
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:29:03 +01:00
James Smith 0af3028151 fix: point doppler and ground-station scheduler at unified TLE store
Both silently fell back to static bundled TLEs after the removal of
routes.satellite._tle_cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:25:29 +01:00
James Smith 5e996654fe refactor: weather sat prediction reads TLEs from unified store
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:23:20 +01:00
James Smith 320fe82348 refactor: hoist TLE reads and batch write-backs in satellite request paths
_resolve_satellite_request now operates on a caller-provided dict and
accumulates write-backs, flushed once per request behind a guard —
avoids per-satellite full-dict copies and store-cache thrash, and a
transient DB error can no longer fail a read request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:20:01 +01:00
James Smith 1c72e15c7c refactor: satellite routes read/write TLEs via unified store
data/satellites.py is no longer rewritten at runtime; it remains as
the read-only seed for the store.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:12:23 +01:00
James Smith 74d5663f73 fix: harden TLE store for cross-process use
- busy_timeout so concurrent app+agent writers wait instead of raising
- seed from _connect() so update-before-first-read can't drop the seed
- regression tests: seed ordering, concurrent writer, default DB path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:07:30 +01:00
James Smith f4a9cb7da6 feat: add unified SQLite-backed TLE store
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:58:09 +01:00
James Smith 2f6afd5e28 test: use shared fake_process fixture in agent mode tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:50:20 +01:00
James Smith 9463d53763 test: address review feedback on fake_process fixture
- document str defaults / bytes for binary-mode callers
- wire __exit__ to False so exceptions are not suppressed
- exercise exited-process path through subprocess.run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:48:13 +01:00
James Smith c177dd354a test: add shared fake_process fixture for complete Popen mocking
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:44:04 +01:00
James Smith d4652017f5 fix: stabilize test suite and repair frontend/backend wiring
- meshcore pin >=2.3.0 (EventType.STATS_CORE floor); setup.sh derives
  optional packages from requirements.txt; Python 3.10 warning
- agent-mode wifi clients proxy route + bare-array response handling
- remove dead AIS/ACARS/VDL2 SPA wiring and orphaned partials/CSS
- agent TLE download to data/tle/ (was littering repo root as gp.php)
- gate deferred background init off under pytest (mock-pollution race)
- complete Popen mocks (context manager protocol, communicate tuples)
- real pipe fds in weather-sat decoder tests (fd 10/11 collision caused
  10s SQLite stalls); satellite tests no longer rewrite data/satellites.py
- register 'live' pytest marker, excluded by default
- update stale test assertions to current APIs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:42:33 +01:00
James Smith b68a53eb53 fix: prevent full page reload when clicking Main Dashboard button
Intercept .nav-dashboard-btn clicks to perform SPA-style navigation
instead of a full page reload. After switchMode() updates the URL to
/?mode=<x> via pushState, clicking href="/" previously caused a round-
trip reload. Now the click handler stops active scans, destroys the
current mode, shows the welcome overlay, and pushes '/' onto history.

Also update popstate to restore the welcome page when navigating back
to '/' with no mode param.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 22:07:41 +01:00
James Smith d68d1ec53a fix(adsb): update Planespotters User-Agent to include contact URL
Planespotters.net now requires a descriptive User-Agent with a contact
URL or email — generic strings return 403. Updated to comply with their
API policy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:34:42 +01:00
James Smith 9c15ece508 fix: hide signalViewWrap entirely in modesWithVisuals to prevent layout bleed
The flex container was still occupying space even when all its children
were hidden, causing a blank box to overlap mode-specific content in
Bluetooth, WiFi, SSTV and other modesWithVisuals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:21:13 +01:00
James Smith fe222c0393 fix: don't restore #output visibility in non-sensor modes
SensorDashboard.applyViewState was resetting output.style.display=''
in the else branch, undoing switchMode's modesWithVisuals hide for
waterfall, morse, ook, and every other mode with its own visuals.
Only touch #output when mode === 'sensor'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:12:09 +01:00
James Smith 68cafe8cd0 fix: move applyViewState calls after output display override in switchMode
switchMode() forces output.style.display='block' for modes not in
modesWithVisuals (line ~4906). Our applyViewState calls were placed
before this line, so the override undid the dashboard hide. Moving
them after ensures SensorDashboard can correctly hide #output in
dashboard mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 17:47:42 +01:00
James Smith d01742678c chore: update satellite TLE data
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:49:53 +01:00
James Smith 31ae70b8fa feat: wire PagerDirectory and SensorDashboard into pager and sensor modes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:25:13 +01:00
James Smith e7f13a5856 fix: escape channel, snr, and reading values in sensor dashboard cards 2026-05-21 13:01:46 +01:00
James Smith a9ed367148 feat: add SensorDashboard JS component 2026-05-21 13:00:09 +01:00
James Smith 2505218385 fix: use CSS variables for accent-green-rgb and accent-purple-rgb in sensor dashboard 2026-05-21 12:58:31 +01:00
James Smith b5c35890af feat: add sensor dashboard view CSS 2026-05-21 12:56:45 +01:00
James Smith 484d9ce21b fix: refresh directory timestamps on applyViewState 2026-05-21 12:55:49 +01:00
James Smith 9353527e1b feat: add PagerDirectory JS component 2026-05-21 12:51:41 +01:00
James Smith fd3ad63971 fix: add display flex to pdir-panel, use accent-purple-rgb variable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 12:49:54 +01:00
James Smith 2e583649d0 feat: add pager directory view CSS 2026-05-21 12:46:31 +01:00
James Smith a3c509aa94 feat: add HTML scaffolding for pager directory and sensor dashboard views
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 12:39:57 +01:00
James Smith f26a820b1d docs: add pager/sensor display revamp implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 12:27:10 +01:00
James Smith 901e7f95e8 docs: add pager/sensor display revamp design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 12:17:04 +01:00
James Smith 592d11aae2 feat: add graticule toggle control to all Leaflet maps
Adds a bottomleft grid button (MapUtils.addGraticuleControl) to every
map in the app — Meshtastic, MeshCore, Drone, SSTV/ISS, BT Locate,
WebSDR, and Weather Satellite — defaulting to visible. The weather
satellite map's bespoke addStyledGridOverlay() is removed in favour of
the shared implementation. Also updates map-utils.css with button
styles and map-utils.js with the new addGraticuleControl() method.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:09:39 +01:00
James Smith 30a0085f1d ci: split test suite into two parallel jobs to prevent OOM
GitHub Actions ubuntu-latest has 7GB RAM. Running all 1362 tests in
a single process exhausts it (~9 min, runner shutdown signal). Split
into two matrix jobs (test_[a-l] and test_[m-z]) so each job starts
with fresh memory, halving peak usage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 08:25:20 +01:00
James Smith b30d883974 fix: resolve CI test failures and OOM kill in satellite tests
- pyproject.toml: sync missing deps (flask-wtf, flask-compress,
  simple-websocket, gunicorn, gevent, psutil, cryptography, meshcore,
  pre-commit) so test_requirements integrity check passes
- tests/conftest.py: set INTERCEPT_DISABLE_AUTH=1 so auth routes
  return 200 instead of 302 in tests
- routes/bluetooth_v2.py: add device_to_dict() helper that flattens
  heuristics to top level for test_bluetooth_api serialization tests
- utils/bluetooth/heuristics.py: evaluate() now returns the device so
  callers can chain; was returning None
- tests/test_satellite.py: reduce hours 48→2 in pass-prediction test
  to prevent OOM kill on GitHub Actions 7GB runner at the 59% mark

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:34:02 +01:00
74 changed files with 8299 additions and 6340 deletions
+11 -2
View File
@@ -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
+12 -1
View File
@@ -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)
+5
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+17 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+13
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+71 -67
View File
@@ -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
View File
@@ -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))
+34 -20
View File
@@ -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
}
+179
View File
@@ -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);
}
+124
View File
@@ -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);
}
+30
View File
@@ -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. */
+2
View File
@@ -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;
-115
View File
@@ -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);
}
-31
View File
@@ -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); }
}
+198
View File
@@ -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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 };
})();
+201
View File
@@ -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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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
View File
@@ -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());
+298
View File
@@ -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();
},
},
};
+1
View File
@@ -213,6 +213,7 @@ const BtLocate = (function() {
flushPendingHeatSync();
scheduleMapStabilization();
});
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(map);
}
// Init RSSI chart canvas
+1
View File
@@ -50,6 +50,7 @@ var DroneMode = (function () {
maxZoom: 19,
}).addTo(_map);
}
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(_map);
}
function _connectSSE() {
+2
View File
@@ -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) {
+2
View File
@@ -137,6 +137,8 @@ const Meshtastic = (function() {
}
}
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(meshMap);
// Handle resize
setTimeout(() => {
if (meshMap) meshMap.invalidateSize();
+2
View File
@@ -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',
+1 -35
View File
@@ -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);
+1
View File
@@ -343,6 +343,7 @@ async function initWebsdrLeaflet(mapEl) {
}
}
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(websdrMap);
mapEl.style.background = '#1a1d29';
return true;
}
+3 -12
View File
@@ -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
View File
@@ -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;
-300
View File
@@ -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 &amp; 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) &mdash; 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 ? ' &bull; FL' + escapeHtml(String(p.flight_level)) : ''}${p.destination ? ' &rarr; ' + 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))} &rarr; ${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>
-218
View File
@@ -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) &mdash; 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&ndash;50, designed for 156&ndash;163 MHz band</li>
<li><strong style="color: var(--text-primary);">Discone:</strong> ~$30&ndash;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>
-242
View File
@@ -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 &amp; 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) &mdash; 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -4
View File
@@ -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
+13 -10
View File
@@ -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
View File
@@ -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
+46 -31
View File
@@ -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)
+123
View File
@@ -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
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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
View File
@@ -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."""
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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
+65 -44
View File
@@ -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
View File
@@ -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"
+92
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
File diff suppressed because it is too large Load Diff
+39 -53
View File
@@ -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)
+19 -24
View File
@@ -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
+129 -124
View File
@@ -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,
+249
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+115
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+48 -44
View File
@@ -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)