Compare commits

...

329 Commits

Author SHA1 Message Date
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
James Smith ea8f72f7ff chore: bump version to 2.27.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:08:42 +01:00
James Smith a3f2fa7b88 fix: resolve two-window hang and sweep UI/theming updates
Fix app becoming unresponsive when two browser windows are open: the
root cause was HTTP/1.1 connection pool exhaustion (6-connection limit
per origin). VoiceAlerts was opening 3 SSE streams per window by
default, so two windows produced 8 connections and permanently starved
all regular HTTP requests.

- voice-alerts.js: default all streams to false (opt-in) to stay within
  the browser connection limit; existing user preferences in localStorage
  are preserved
- routes/alerts.py: replace direct AlertManager.stream_events() with
  sse_stream_fanout so both windows receive every alert instead of
  competing for the same queue
- routes/bluetooth_v2.py: same fanout fix via subscribe_fanout_queue,
  preserving named SSE events (device_update, scan_started, etc.)

Also includes accumulated UI/theming changes: accent-cyan CSS variable
sweep across mode CSS/JS files, standalone dashboard pages, template
updates, satellite TLE data refresh, and tile provider default rename.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:01:10 +01:00
James Smith 5100f55586 fix: introduce --accent-cyan-rgb to make all opacity variants theme-aware
All files used hardcoded rgba(74, 163/158, 255, X) values in actual CSS
rules that CSS variable overrides couldn't touch. Solution: add
--accent-cyan-rgb triplet to variables.css root/light/enhanced blocks,
then replace every rgba(74,1xx,255,) occurrence across all CSS files
with rgba(var(--accent-cyan-rgb),). Enhanced tier sets the triplet to
200, 150, 40 (amber), so tscm.css panel bg, index.css card borders,
and all other tinted surfaces go amber automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 08:53:25 +01:00
James Smith 9d41ffbb59 fix: apply UI tier to standalone dashboard pages (ADS-B, AIS, Satellite)
Each dashboard is a separate HTML page that doesn't inherit the main SPA's
localStorage restore. Add a synchronous tier-restore script before CSS loads
so html[data-ui-tier] selectors fire on first paint.

Also add enhanced/lean tier override blocks to each dashboard CSS to remap
the dashboard-local variables (--bg-dark, --bg-panel, --radar-cyan, etc.)
that variables.css doesn't cover, and add lean-mode scanline/bg hide rules
since components.css is not loaded on these pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 08:44:29 +01:00
James Smith 517eb8cb77 revert: restore brand logo to fixed blue (#00d4ff) — brand identity not UI chrome
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 08:41:11 +01:00
James Smith 9d72c88a28 fix: sweep final hardcoded cyan from mode JS files and CSS
- proximity-radar.js: fix missed dot stroke in new-device creation path
- gps.js: GPS constellation color via object getter; globe atmosphere reads CSS var
- websdr.js: globe atmosphere, map markers, popup buttons, point label use CSS var
- subghz.js: canvas strokeStyle reads --accent-cyan
- sstv.js: ISS track polyline reads --accent-cyan
- app.js: info message border-left uses var(--accent-cyan)
- subghz.css, gps.css: replace all #00d4ff with var(--accent-cyan)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:08:49 +01:00
James Smith e1922d7a30 fix: signal-guess why-button hover uses --accent-cyan CSS variable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:05:57 +01:00
James Smith c48d66d1b4 fix: replace remaining hardcoded cyan in map utilities and mode JS files
- map-utils.js: range rings and reticle crosshair SVG use --accent-cyan
- drone.js: trail polyline color reads --accent-cyan for non-threat contacts
- weather-satellite.js: NOAA APT pass track reads --accent-cyan
- space-weather.js: solar wind chart border/bg/axis ticks read --accent-cyan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:05:38 +01:00
James Smith fbea33e7cb fix: replace hardcoded cyan with CSS variable across brand SVGs and components
- Brand logo SVGs (.logo, .welcome-logo, .brand-i) now follow --accent-cyan
  via CSS rules that override SVG presentation attributes
- proximity-radar.js: sweep, center dot, gradient stops, and selection rings
  all use var(--accent-cyan) in style attrs or read getComputedStyle at runtime
- system.js updateGlobePosition: observer point color reads CSS variable
- .bt-detail-address MAC address text uses var(--accent-cyan)
- Enhanced tier gets --visual-edge-cyan/--visual-glow-cyan amber overrides

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:04:47 +01:00
James Smith af26a01703 fix: read --accent-cyan CSS var for globe atmosphere and point color
Globe.gl WebGL cannot be styled via CSS; read the computed accent color
at init time so Enhanced tier's amber (#c89628) is applied correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:00:50 +01:00
James Smith 336d0d81ec fix: use var(--accent-cyan) in wifi proximity radar SVG so enhanced tier colors it amber 2026-05-19 22:57:24 +01:00
James Smith 076d17da18 fix: use html[data-ui-tier] selector to beat index.css :root specificity; add body tier rules to index.css 2026-05-19 22:49:40 +01:00
James Smith 5b4b99707a fix: hide decorative elements (scanline, globe, radar-bg) in lean tier 2026-05-19 22:42:34 +01:00
James Smith db7b056cf4 Merge branch 'worktree-ui-tiers' 2026-05-19 22:36:52 +01:00
James Smith eb0512b3c0 chore: remove orphaned icon-effects CSS (replaced by tier toggle) 2026-05-19 22:30:25 +01:00
James Smith cafc2554de feat: retire data-animations from index.html, login.html, and base_dashboard.html 2026-05-19 22:25:38 +01:00
James Smith 2b25d5cbad feat: add Display Mode step to first-run setup modal 2026-05-19 22:23:58 +01:00
James Smith 0a75322ad1 feat: replace animations toggle with display mode selector in settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:22:05 +01:00
James Smith 95776f5519 feat: add tier toggle button to nav; migrate data-animations restore to data-ui-tier 2026-05-19 22:20:31 +01:00
James Smith 678aefd76e feat: add lean/enhanced component overrides; retire data-animations component CSS 2026-05-19 22:18:48 +01:00
James Smith 41a720f1f6 feat: add lean/enhanced layout overrides; retire data-animations CSS
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:16:50 +01:00
James Smith 0b5d858187 feat: add lean/enhanced body background overrides 2026-05-19 22:14:25 +01:00
James Smith e65a25e526 feat: add lean and enhanced CSS variable override blocks 2026-05-19 21:55:01 +01:00
James Smith dbe2003d75 fix: guard _looked_up_icaos popitem against concurrent clear(); add eviction tests
contextlib.suppress(KeyError) around popitem prevents a crash in the SBS
parser thread if stop_adsb() calls clear() concurrently between the len()
check and the popitem call.

Two unit tests verify FIFO eviction semantics and duplicate-key no-op.
2026-05-19 17:47:21 +01:00
James Smith a5f92ded37 perf: cap _looked_up_icaos at 50 000 entries with LRU eviction 2026-05-19 15:02:46 +01:00
James Smith f7cf0a87cc style: use consistent early-return guard in WiFi _matches filter 2026-05-19 14:56:21 +01:00
James Smith 902a21fc40 perf: combine WiFi network filters into single list pass
Replace four sequential list comprehensions (band → security → hidden → min_rssi)
with a single pass using a helper function. Reduces algorithmic complexity from O(4n)
to O(n) when multiple filters are applied. All WiFi tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:54:42 +01:00
James Smith eeaf87c7f2 perf: move ADS-B SSE snapshot priming into generator 2026-05-19 14:51:06 +01:00
James Smith e6e6cb3b9a fix: always update fingerprint stability; assert tracker fields preserved on skip
- Move fingerprint stability update before early return so it updates even when payload hash matches
- Remove duplicate stability assignment from detect_tracker result block
- Add assertion in test to verify tracker fields are preserved when detection is skipped

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:49:51 +01:00
James Smith 260240728a perf: skip tracker signature scan when BLE payload fingerprint is unchanged 2026-05-19 13:14:16 +01:00
James Smith 0e0e17b089 style: document cleanup now-capture and widen test sleep margin 2026-05-19 13:10:44 +01:00
James Smith efc14b4de0 test: use threading to correctly exercise cleanup re-validation guard 2026-05-19 13:08:32 +01:00
James Smith 6ed24b758d test: fix cleanup re-validation test to exercise actual cleanup()
Replace manual reimplementation of snapshot/delete logic with actual
store.cleanup() call. Uses mocked time.time to simulate the scenario
where entries refreshed between snapshot and deletion survive due to
re-validation guard.

Fixes: test was passing without actually calling the subject under test
2026-05-19 12:36:14 +01:00
James Smith 646eb09e1d perf: minimize DataStore cleanup lock hold time
Modify DataStore.cleanup() to minimize lock hold duration:
- Snapshot timestamps under lock (brief O(1) list copy)
- Compute expired keys outside lock (no contention during O(n) scan)
- Re-acquire lock only for deletion with re-validation
  (ensures entries refreshed between snapshot and deletion are not deleted)

This reduces blocking of reader threads and prevents latency spikes
during periodic cleanup of large stores (10K+ entries).

Also adds tests:
- test_cleanup_removes_expired_keeps_fresh: basic cleanup behavior
- test_cleanup_does_not_delete_refreshed_entry: re-validation guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 12:27:25 +01:00
James Smith 1dd3e485a6 chore: remove project-local CLAUDE.md 2026-05-19 11:53:44 +01:00
James Smith 77cdd56641 debug(meshcore): enable BLE debug mode and disconnect before connect
- Enable debug=True on MeshCore.create_ble() to surface verbose logs
- Disconnect any existing BlueZ connection before bleak connects to
  avoid conflicts from prior bluetoothctl/pairing sessions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:15:24 +01:00
Smittix b26d94c967 Merge pull request #229 from smittix/fix/meshcore-connect-error-reporting
fix(meshcore): surface backend error messages and extend polling window
2026-05-13 21:00:55 +01:00
James Smith 98e01f4c5b fix(meshcore): surface backend error messages and extend polling window
- Store last status message on MeshcoreClient so error details survive
  beyond the SSE event (which isn't active during connecting state)
- Status endpoint now returns message field so the frontend can show
  the real reason (e.g. 'Connection failed after retries: ...')
- Extend JS polling from 30s to 90s to outlast the backend's 65s
  retry sequence (5+15+45s delays) before declaring timeout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:59:33 +01:00
Smittix 32f245e6ef Merge pull request #228 from smittix/fix/meshcore-ble-scan-gevent
fix(meshcore): run BLE scan in dedicated thread to avoid gevent conflict
2026-05-13 20:53:02 +01:00
James Smith bf50cb4acd fix(meshcore): run BLE scan in dedicated thread to avoid gevent conflict
asyncio.run() called from a gevent-patched Flask thread fails under
gunicorn+gevent. Run the one-shot scan in a ThreadPoolExecutor thread
with its own event loop, matching how AsyncWorker handles it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:52:44 +01:00
Smittix e778efa5b6 Merge pull request #227 from smittix/fix/meshcore-ble-scan-500
fix(meshcore): fix BLE scan 500 and allow scan before connect
2026-05-13 20:50:06 +01:00
James Smith 7d537998ca fix(meshcore): revert wrong scan_ble_sync route call, fix scan without worker
- Revert route to scan_ble() — scan_ble_sync() lives on AsyncWorker,
  not MeshcoreClient; the 500 was caused by our previous fix
- MeshcoreClient.scan_ble() now runs a one-shot asyncio scan when no
  worker is active, so Scan works before Connect is pressed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:49:44 +01:00
Smittix daaf3d2158 Merge pull request #226 from smittix/fix/meshcore-ble-scan-method
fix(meshcore): call scan_ble_sync() in BLE scan route
2026-05-13 20:47:38 +01:00
James Smith 2dfdcd39f1 fix(meshcore): call scan_ble_sync() not scan_ble() in ble/scan route
Route was calling a non-existent method, causing a 500 on every scan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:47:07 +01:00
Smittix 5e568f59ba Merge pull request #225 from smittix/fix/meshcore-ble-scan
fix(meshcore): BLE scan feedback and cancel while connecting
2026-05-13 20:43:02 +01:00
James Smith ae5664dbb4 fix(meshcore): BLE scan feedback and cancel-during-connecting
- Scan button shows 'Scanning...' and disables during fetch; shows
  'No devices found' or 'Scan failed' on empty/error result; auto-
  selects device if only one is returned
- Disconnect button now enabled during 'connecting' state so users
  can cancel a stuck connection and retry with a different device

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:42:25 +01:00
Smittix e64d82ebb5 Merge pull request #224 from smittix/fix/meshcore-connect-timeout
fix(meshcore): show error when connection times out
2026-05-13 20:39:30 +01:00
James Smith c5fdf7f7e9 fix(meshcore): show error state when connection times out
After 30s of polling with no response, update UI to 'Connection timed
out' instead of silently leaving the dot stuck on Connecting...

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:39:16 +01:00
Smittix a59d4ec603 Merge pull request #223 from smittix/fix/meshcore-ux-feedback
fix(meshcore): layout height, connection polling, and modal visibility
2026-05-13 17:33:05 +01:00
James Smith 2cdf156cd0 fix(meshcore): fix layout height, connection polling, and modal visibility
- Add base flex properties to #meshcoreVisuals so it fills full panel
  height when meshtastic.css hasn't been lazily loaded yet
- Poll /meshcore/status every 2s after Connect click so the UI
  transitions out of "Connecting..." when the backend is ready
- Fix Add Contact and Traceroute modals to use .show class pattern
  (signal-details-modal uses opacity/visibility transitions, not display)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:59:59 +01:00
Smittix 7c535d7ba8 Merge pull request #221 from smittix/fix/meshcore-fill-height
fix(meshcore): fill panel height by removing intermediate wrapper div
2026-05-13 13:02:02 +01:00
James Smith f7d8af493a fix(meshcore): make strip and body direct children of visuals container
Remove the intermediate #meshcoreMode wrapper div that was breaking the
flex height chain. Strip and body are now direct children of
#meshcoreVisuals (matching the Meshtastic pattern), so flex: 1 propagates
correctly and the content fills the full panel height.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 12:16:02 +01:00
Smittix 46f076077b Merge pull request #220 from smittix/fix/meshcore-sidebar-hidden-css
fix(meshcore): add mesh-sidebar-hidden rules to meshcore.css
2026-05-13 12:06:36 +01:00
James Smith 020126b6e0 fix(meshcore): add mesh-sidebar-hidden rules to meshcore.css
The sidebar-hiding CSS lives only in meshtastic.css, which is lazily
loaded and may not be present when switching directly to Meshcore mode.
Duplicating the three rules into meshcore.css ensures the generic
sidebar is correctly hidden and the output panel fills the screen
regardless of load order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:32:14 +01:00
Smittix 99268e47b8 Merge pull request #219 from smittix/fix/meshcore-strip-layout-and-map-tiles
fix(meshcore): restyle to strip layout, fix map tiles
2026-05-13 10:54:35 +01:00
James Smith 7940728b30 fix(meshcore): restyle to strip layout, fix map tiles
Replaced inner-sidebar layout (which collided with the generic app
sidebar) with a Meshtastic-style top connection strip + body row.
Contacts/nodes panel sits left of the tabbed content area, matching
the established pattern. Map now uses Settings.createTileLayer() with
a dark CartoDB fallback instead of plain OSM light tiles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:23:31 +01:00
Smittix c4d6d50687 Merge pull request #218 from smittix/fix/meshcore-visuals-layout
fix(meshcore): move UI into visuals container, fix layout
2026-05-13 10:15:56 +01:00
James Smith 52ab1b60a3 fix(meshcore): move UI into visuals container, fix layout
meshcoreMode partial was inside the generic .sidebar which gets hidden
when meshcore mode is active. Moved the include into meshcoreVisuals
(inside the output panel) — matching the same pattern as Meshtastic.
Also overrides mesh-visuals-container's column/padding defaults so the
meshcore sidebar+main row layout renders correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:15:14 +01:00
Smittix 9641c43384 Merge pull request #217 from smittix/fix/meshcore-active-display
fix(meshcore): show mode panel when active
2026-05-13 10:11:10 +01:00
James Smith 1f7e0881f3 fix(meshcore): show mode panel when active
meshcore.css was missing the .active display rule, so the meshcoreMode
div (display:none inline) was never made visible when the mode was
selected, leaving only the generic sidebar visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:10:25 +01:00
Smittix b2cc6b65ad Merge pull request #216 from smittix/fix/meshcore-nav-and-card-styling
fix(meshcore): add to nav and fix welcome card styling
2026-05-13 10:06:40 +01:00
James Smith 28a779b91b fix(meshcore): add to nav and fix welcome card styling
Meshcore was missing from both the desktop Wireless dropdown and mobile
nav. The welcome card also used non-standard div/emoji markup instead of
the SVG icon pattern used by every other mode, causing wrong font and
colour rendering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:02:45 +01:00
James Smith e397a69dae fix(drone): use Settings tile layer for theme-aware map
Replace hardcoded OSM tiles with Settings.createTileLayer() + registerMap()
so the drone map respects the user's map theme preference and switches
automatically with light/dark theme changes. Falls back to CartoDB dark_all
if Settings is unavailable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 09:58:04 +01:00
James Smith 3554817f91 fix(drone): fix main panel height collapse in flex output container
Replace height:100% with flex:1+min-height:0 on .drone-visuals-container
so it fills the flex-column .output-panel correctly (height:100% collapses
inside a scroll container). Add min-height:400px to .drone-main-map so
Leaflet has pixel dimensions to render into.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 09:45:00 +01:00
James Smith 410225d54d fix(drone): conform to established SPA patterns throughout
- Device population: move refreshDroneDevices() inline to index.html
  (same pattern as refreshTscmDevices) and call it from switchMode
  alongside DroneMode.init(); remove _refreshDevices/populateSelect
  from drone.js which was never guaranteed to run before lazy-load
  completed, causing selects to stay on "Loading…" permanently

- IIFE pattern: change from named IIFE + window.DroneMode assignment
  to var DroneMode = (function(){...return{...}})() matching OOK/
  SpyStations convention

- Init guard: add _initialized flag (OOK state.initialized pattern);
  re-entry after destroy() re-registers map/SSE cleanly without
  duplicating click listeners on every mode switch

- Lifecycle: destroy() resets _initialized = false so map and SSE
  are correctly rebuilt on re-entry

- Stop phase: add isDroneRunning tracking variable in index.html;
  _setRunningUI() syncs it; switchMode stop phase now POSTs
  /drone/stop when leaving drone mode while active, matching TSCM

- /drone/devices: add monitor_capable field to WiFi interfaces,
  add running_as_root and warnings array to response (mirrors
  /tscm/devices shape); add os import; show privilege warning div
  in drone.html when not running as root

- drone.html: remove for= attribute from SDR label (plain <label>
  inside .form-group matches TSCM convention); add droneDeviceWarnings
  div for privilege warnings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 09:33:38 +01:00
James Smith 4ba8a40af9 feat(drone): replace freeform inputs with populated device selects
Add /drone/devices endpoint that enumerates available WiFi interfaces
(via iw/iwconfig) and RTL-SDR devices (via SDRFactory.detect_devices),
matching the pattern used by TSCM.

Sidebar WiFi interface and RTL-SDR inputs are now <select> elements
populated on init() from /drone/devices, consistent with how other
modes expose hardware selection. HackRF checkbox remains as a toggle
since it's a binary capability rather than an enumerated device list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 09:25:21 +01:00
James Smith 6523686aca feat(drone): add main visuals panel with map and contact list
- Sidebar inputs now use form-group/label pattern matching other modes
- Move map and contact list out of sidebar into a dedicated droneVisuals
  main panel (same pattern as tscm, spystations, etc.)
- droneVisuals: stats header (contacts / non-compliant / high-risk),
  left contact card panel, and full-height Leaflet map on the right
- Wire droneVisuals into switchMode display toggle and modesWithVisuals
  so the shared signal-feed output is hidden when drone mode is active
- Add invalidateMap() to force Leaflet to recalculate after the
  container becomes visible
- Stats now update both sidebar counts and main panel values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 09:16:54 +01:00
James Smith 2475e5dd5a fix(drone): show sidebar panel and expose SDR config options
Remove inline style="display: none;" that was preventing the droneMode
panel from becoming visible when the active class was toggled — inline
styles override CSS class rules without !important. Add RTL-SDR device
index and HackRF toggle inputs that the backend already accepted but
were never surfaced in the UI; wire them through to the /drone/start
POST body.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 09:07:42 +01:00
James Smith 8f6bfb4df1 feat(meshcore): wire Meshcore into index.html (14 insertion points) 2026-05-11 15:47:27 +01:00
James Smith 36a1542176 feat(meshcore): add frontend JS module (IIFE, SSE, map, telemetry, traceroute) 2026-05-11 14:58:45 +01:00
James Smith 71011dd67c feat(meshcore): add HTML partial (sidebar, tabs, modals)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:58:29 +01:00
James Smith 173ddc9eac feat(meshcore): add CSS for Meshcore mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:56:58 +01:00
James Smith 53699482e1 feat(meshcore): register blueprint and add meshcore dependency
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:55:37 +01:00
James Smith e5c5afb158 test(meshcore): assert error message propagated in on_error test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:54:30 +01:00
James Smith f2af2ad0b6 test(meshcore): fix BLE docker test and add library boundary isolation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:51:45 +01:00
James Smith 3f6f8a5695 test(meshcore): add integration tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:49:05 +01:00
James Smith 0d9bb53722 test(meshcore): strengthen connect and send boundary tests
Add 237-char boundary test proving the send limit accepts exactly 237
characters, and upgrade connect tests to assert the correct config
dataclass type and field values are passed to connect().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:47:10 +01:00
James Smith d84cd41896 test(meshcore): add missing coverage for 7 endpoints + SSE keepalive
Adds 16 new tests covering POST /disconnect, GET /ble/scan, GET /stream
(keepalive and event data), GET /messages, GET /nodes, GET /contacts,
GET /telemetry/<node_id>, and GET /repeaters, bringing total from 17 to 33.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:44:11 +01:00
James Smith ed4b6ef897 test(meshcore): add route tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:41:49 +01:00
James Smith f50f5e2d44 feat(meshcore): add Flask blueprint with all 15 endpoints + SSE stream 2026-05-11 12:35:16 +01:00
James Smith 71eaf1d22a fix(meshcore): fix traceroute logging, asyncio task leaks, stop race, channel sender, and battery cap
- Log node_id hint in request_traceroute instead of silently dropping it
- Replace asyncio.shield/wait_for pattern with _wait_or_stop() to prevent orphan tasks on retry delays
- Poll _stop_event every 1s in _do_connect keep-alive loop to handle stop() race before _asyncio_stop is set
- Extract pubkey_prefix/sender_id in _on_channel_msg instead of hardcoding "unknown"
- Close coroutine and log in _submit() when worker is not running to prevent ResourceWarning
- Cap battery_pct at 100 to prevent values exceeding 100%

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:33:31 +01:00
James Smith ba02f761c6 feat(meshcore): add async worker bridge (utils/meshcore_client.py)
Implements AsyncWorker — the daemon asyncio thread that owns the meshcore
library connection, subscribes to all relevant EventTypes, and feeds events
back into MeshcoreClient via on_message/on_node/on_telemetry/on_traceroute/
on_connected/on_error. Includes retry-with-backoff (3 attempts: 5s/15s/45s),
thread-safe send_text/request_traceroute/scan_ble_sync for Flask callers,
and a standalone _scan_ble() coroutine using bleak.BleakScanner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 11:18:50 +01:00
James Smith 80bbdb2c09 fix(meshcore): fix thread safety in _set_state/connect, add missing tests
- Lock-protect `get_state` and `_set_state` to prevent data race
  between Flask and asyncio daemon threads
- Atomically check-and-set CONNECTING guard in `connect()` to close
  TOCTOU window between concurrent Flask threads
- Push status events outside the lock in both `_set_state` and
  `connect()` to avoid potential deadlock
- Add TestMeshcoreContact, TestMeshcoreClientStateMachine tests
  covering to_dict keys, queue push on state change, message append
  and 500-item cap (9 -> 13 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 11:12:11 +01:00
James Smith 6807ee6878 fix(meshcore): fix test isolation, enum value assertions, and os import 2026-05-11 10:39:32 +01:00
James Smith 04d2e1a7bf feat(meshcore): add data model, connection config, MeshcoreClient skeleton
Implements utils/meshcore.py with all dataclasses (MeshcoreMessage,
MeshcoreNode, MeshcoreContact, MeshcoreTelemetry, MeshcoreTraceroute),
connection configs (SerialConfig, TCPConfig, BLEConfig), ConnectionState
enum, serial port discovery, and the MeshcoreClient singleton skeleton.
Adds tests/test_meshcore_client.py covering all dataclasses, availability
check, and state enum (8/8 tests passing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 10:12:23 +01:00
James Smith 07e45f508a docs: add Meshcore implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 14:37:19 +01:00
James Smith 2b9665c723 docs: add Meshcore integration design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 14:22:07 +01:00
James Smith c73d02f76c fix: remove opendroneid dependency incompatible with Python 3.11
The opendroneid package caps at Python <3.11, breaking Docker builds on
the current python:3.11-slim base image. The package is unused — drone
Remote ID parsing is handled natively via scapy and struct in
utils/drone/remote_id.py.

Closes #214

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:10:06 +01:00
Smittix 5048711acb Merge pull request #213 from smittix/fix/adsb-photos-and-drone-docs
fix(adsb): fix aircraft photo display and add Drone Intelligence docs
2026-05-05 09:25:47 +01:00
James Smith 62e53c5dfa fix(adsb): fix aircraft photo display and add Drone Intelligence docs
- Fix stale DOM refs in fetchAircraftPhoto: elements were captured before
  await fetch(), but showAircraftDetails rebuilds innerHTML on every RAF
  update, leaving the async path writing to detached nodes. Now re-queries
  the DOM after await, and the cache (synchronous) path queries inline so
  refs are always fresh.
- Add thumbnail fallback in aircraft_photo route: fall back to thumbnail
  when thumbnail_large.src is absent rather than returning null.
- Add Drone Intelligence to nav, help modal, cheat sheets, README, and docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 09:24:30 +01:00
James Smith 333c5cf8d3 feat(drone): merge Drone Intelligence module
Multi-vector UAV detection mode: Remote ID (WiFi/BLE ASTM F3411),
RTL-SDR 433/868MHz control-link detection, HackRF 2.4/5.8GHz wideband.

Workers feed a shared observation queue; DroneCorrelator merges into
DroneContact objects with TTL store, risk scoring, and SSE streaming.
Frontend: two-panel sidebar + Leaflet map with contact cards and trails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 08:36:27 +01:00
James Smith d033a95b0e chore: add .worktrees/ to .gitignore 2026-05-05 08:36:16 +01:00
James Smith 3b480eb183 fix(hackrf): resolve 'Tools Missing' on RPi when hackrf_info is present
Two root causes behind HackRF showing as unavailable when tools are installed:

1. get_tool_path() didn't search /usr/local/bin on Linux. HackRF tools built
   from source (as in the Dockerfile) land there, but the path wasn't checked
   when sudo/service environments have a restricted PATH.

2. check_hackrf() only tested hackrf_transfer, but the health check tests
   hackrf_info — both come from the same apt package but a user could have one
   visible and not the other. Now either binary confirms the tools are present.
   hackrf_transfer is still required for actual RX/TX operations.

Fixes #212

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 08:36:16 +01:00
James Smith 8632e31c01 fix(drone): resolve critical pipeline, frontend, and input validation issues
Data pipeline (critical): scanners/detectors now write to a separate _obs_queue;
a relay thread reads observations and calls correlator.process(), which emits
processed DroneContact dicts to drone_queue for SSE. Without this the SSE stream
received raw unserializable dataclass objects causing JSON errors.

Frontend (critical):
- Add droneContactList container to drone.html so contact cards render
- Add droneMap container and initialize Leaflet in drone.js init()
- Define dsc-distress-pulse keyframes in drone.css (was referenced but missing)
- Fix SSE reconnect: null _sse before setTimeout to prevent _connectSSE no-op loop

Other fixes:
- Validate rtl_sdr_index with validate_device_index(), return 400 on bad input
- Move _ensure_workers() inside _drone_lock to prevent double-initialization race
- Add double-call guard to RemoteIDScanner.start()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:47:12 +01:00
James Smith 14e6305aa4 feat(drone): add frontend JS, modeCatalog entry, and switchMode wiring
Creates static/js/modes/drone.js IIFE module (SSE consumer, map markers,
contact cards, start/stop controls) and wires it into index.html via
INTERCEPT_MODE_SCRIPT_MAP, modeCatalog (intel group), and switchMode
init/destroy handlers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 17:44:07 +01:00
James Smith e059be2d84 feat(drone): add HTML partial, CSS, and index.html mode panel wiring
- Create templates/partials/modes/drone.html with drone mode sidebar panel
- Create static/css/modes/drone.css with scoped drone UI styles
- Wire drone mode into index.html: CSS map entry, partial include, classList toggle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 17:41:02 +01:00
James Smith 1a99a7213f fix(drone): add lock on _drone_running and null guards before start() calls
Concurrent POST /drone/start under gevent would race on _drone_running;
lock mirrors the ais_lock / dsc_lock pattern used throughout the codebase.
Null guards prevent AttributeError if worker constructors fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 17:39:23 +01:00
James Smith f9e8fa896d feat(drone): add Flask blueprint, register routes, wire drone_queue
Implements Task 5: creates routes/drone.py with /status, /contacts,
/start, /stop, and /stream (SSE fanout) endpoints; registers the
drone_bp blueprint in routes/__init__.py; adds drone_queue to app.py;
adds opendroneid>=1.0 to requirements.txt. All 39 drone tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 17:37:02 +01:00
James Smith 59713ffc22 fix(drone): harden RFDetector threading, subprocess lifecycle, and frequency accuracy
- Replace _running bool with threading.Event for correct cross-thread visibility
- Add _proc_lock to guard _rtl_proc/_hackrf_proc across worker/main threads
- Use register_process + safe_terminate (pipe close + SIGKILL fallback on timeout)
- Compute HackRF frequency as band midpoint (hz_low+hz_high)//2, not hz_low
- Guard start() for idempotency — double-call no longer leaks threads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 17:33:55 +01:00
James Smith 681a498461 test(drone): fix test_start_stop isolation and add out-of-band filter coverage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 12:53:41 +01:00
James Smith e8b94b6efc feat(drone): add RFDetector for rtl_433 and hackrf_sweep control-link detection
Implements RFDetector class that wraps rtl_433 (433/868MHz) and hackrf_sweep
(2.4/5.8GHz) subprocesses, emitting RFObservation objects onto a shared queue.
Includes signature matching, frequency band validation, and power thresholding.

- _handle_rtl433_line(): Parse JSON output, filter drone bands, emit observations
- _handle_hackrf_line(): Parse CSV output, average power levels, threshold at -90dBm
- start()/stop(): Manage subprocess threads for concurrent RF detection
- Graceful handling of missing tools (rtl_433, hackrf_sweep)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 12:52:03 +01:00
James Smith 5dda961dbb fix(drone): assign self._sniffer only after successful AsyncSniffer.start()
Prevents a non-running sniffer object being stored when start() raises
(e.g. permission denied or interface not found).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 12:42:42 +01:00
James Smith a6ce5d5426 feat(drone): add RemoteIDScanner with BLE/WiFi ASTM F3411 parsing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 12:39:06 +01:00
James Smith 772b5d0973 feat(drone): add DroneCorrelator with TTL store and risk scoring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 12:11:04 +01:00
James Smith b707468cb6 feat(drone): add data models and RF signature table 2026-05-03 11:45:59 +01:00
James Smith e33dff1ab9 chore: add .worktrees/ to .gitignore 2026-05-03 11:13:07 +01:00
James Smith 58222b3474 fix(hackrf): resolve 'Tools Missing' on RPi when hackrf_info is present
Two root causes behind HackRF showing as unavailable when tools are installed:

1. get_tool_path() didn't search /usr/local/bin on Linux. HackRF tools built
   from source (as in the Dockerfile) land there, but the path wasn't checked
   when sudo/service environments have a restricted PATH.

2. check_hackrf() only tested hackrf_transfer, but the health check tests
   hackrf_info — both come from the same apt package but a user could have one
   visible and not the other. Now either binary confirms the tools are present.
   hackrf_transfer is still required for actual RX/TX operations.

Fixes #212

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 09:24:22 +01:00
Smittix b78ca51db1 Merge pull request #202 from mitchross/misc-fixes
Fix Meteor LRPT, global timezone, VDL2 correlation, and weather sat UX
2026-04-26 15:05:56 +01:00
Mitch Ross 4a149525bd Merge main into misc-fixes and address PR #202 review
Sync with upstream main and fix required items from review:

- updateTimelineLabels() now uses InterceptTime API (getTimezone/getIANA)
  instead of the stale selectedTimezone/TZ_MAP globals that were removed
  during the earlier InterceptTime refactor — fixes ReferenceError on TZ
  change and pass refresh.

- Remove profiles: [basic] from the intercept service in
  docker-compose.yml so bare `docker compose up -d` still starts the
  main service. Profile-gated services (intercept-history, adsb_db)
  stay as-is.
2026-04-24 16:34:09 -04:00
Smittix dd1c6b8b62 Merge pull request #210 from smittix/fix/wefax-stations-missing-in-docker
fix(wefax): include station JSON in Docker image and handle missing file
2026-04-22 16:45:03 +01:00
James Smith a982ff5885 fix(wefax): include station JSON in Docker image and handle missing file
data/*.json was excluded by .dockerignore, so wefax_stations.json was
never copied into the container image. The volume mounts in docker-compose
only cover subdirectories (weather_sat, adsb, etc.), leaving the stations
file inaccessible at runtime — causing the /wefax/stations route to 500
and the station/frequency dropdowns to appear empty.

Also adds a graceful file-existence check in load_stations() so a missing
file logs a warning and returns an empty list instead of an unhandled
FileNotFoundError.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:51:24 +01:00
James Smith 7cf94cce14 fix(sstv): fix inaccurate ISS orbit tracking — three root causes
1. iss_schedule() was importing TLE_SATELLITES directly from data/satellites.py
   (hardcoded, 446 days stale) instead of the live _tle_cache kept fresh by
   the 24h auto-refresh. Add get_cached_tle() to satellite.py and use it.

2. Ground track was a fake sine wave (inclination * sin(phase)) that mapped
   longitude offset directly to orbital phase, ignoring Earth's rotation under
   the satellite (~23° westward shift per orbit). Replace with a /sstv/iss-track
   endpoint that propagates the orbit via skyfield SGP4 over ±90 minutes, and
   update the frontend to call it. Past/future track rendered with separate
   polylines (dim solid vs bright dashed).

3. refresh_tle_data() updated _tle_cache in memory but never persisted back to
   data/satellites.py, so every restart reloaded the stale hardcoded TLE. Add
   _persist_tle_cache() called after each successful refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 15:37:02 +01:00
James Smith 1dc45a285d perf(css): fix rendering slowdown on low-power hardware (Pi5)
- Remove backdrop-filter: blur(5px) from .card and .panel — on ARM/Linux
  Chromium this is software-rendered, causing severe CPU overhead at 42+
  instances. The opaque surface gradient makes blur imperceptible anyway.
- Remove inset vignette box-shadow from .panel added in 51c1014
- Rewrite panel-pulse keyframes to animate opacity only (was box-shadow,
  which triggers CPU repaint every frame; opacity is compositor-only)
- Gate body::before and .visuals-container::after scanline pseudo-elements
  under [data-animations="off"] — the toggle was blind to both
- Gate panel-indicator pulse under [data-animations="off"] for consistency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 14:54:34 +01:00
James Smith c70c93c814 chore: clean up project root
- Delete stale aircraft_db.json + aircraft_db_meta.json (code uses data/adsb/)
- Delete orphaned gp.php (TLE data fetched at runtime from celestrak.org)
- Move oui_database.json → data/ and update path in data/oui.py
- Move favicon.svg → static/ and update send_file path in app.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 13:05:35 +01:00
James Smith f51682f929 chore: exclude docs/superpowers/ from git tracking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 12:59:05 +01:00
James Smith f12f4145ef docs(alerts): document webhook/notification system in USAGE.md
fix(db): use gevent-safe local storage for DB connections under gunicorn+gevent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 12:45:58 +01:00
James Smith 6dce911172 fix(maps): remove duplicate overlays — drop HUD panels from APRS/GPS (have own header), drop MapUtils range rings from ADS-B (drawRangeRings() owns them) 2026-04-13 23:46:03 +01:00
James Smith 8ae19beef6 fix(maps): remove defer from map-utils.js in index.html for consistency 2026-04-13 23:33:40 +01:00
James Smith 3693b02cb9 refactor(radiosonde): use MapUtils.init + Settings tile layer on sonde map
Replace hardcoded L.map() + CartoCD dark tile layer with MapUtils.init()
and add tactical overlays. Adds test verifying the cartocdn URL is gone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:27:36 +01:00
James Smith ac2f7ea032 fix(aprs): correct aprsMarkers variable name and reset aprsMapOverlays on teardown 2026-04-13 23:15:53 +01:00
James Smith 24f12b1220 refactor(aprs,gps): use MapUtils.init + HUD panels on APRS and GPS maps
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:12:46 +01:00
James Smith 88db107691 fix(satellite): use MapUtils._buildReticle consistently in updateObserverMarker 2026-04-13 23:03:46 +01:00
James Smith 7dfefb48e6 refactor(satellite): use MapUtils.init + HUD on ground track map
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 22:56:20 +01:00
James Smith 12af6e250e refactor(ais): use MapUtils.init + HUD panels on vessel map
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 22:41:26 +01:00
James Smith 5ffd9e5fb3 refactor(adsb): use MapUtils.init + tactical overlays on radar map
Replace custom createFallbackGridLayer/upgradeRadarTilesFromSettings with
MapUtils.init(), add range ring + reticle + HUD panel overlays via
MapUtils.addTacticalOverlays(), and wire updateCount/updateReticle into
the SSE aircraft handler and drawRangeRings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 22:35:43 +01:00
James Smith 7d78bb45d6 fix(maps): add !important to glass popup overrides to beat global index.css rules 2026-04-13 22:29:23 +01:00
James Smith 9a82328de2 feat(maps): add map-utils.css for HUD panels, glass popup, range ring labels 2026-04-13 22:27:19 +01:00
James Smith 51f8a6f65b fix(maps): fix _upgradeTiles race guard, interval leak, graticule events, ring labels
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 22:24:23 +01:00
James Smith 99edea33e3 feat(maps): add MapUtils shared map initialisation utility
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 22:19:44 +01:00
James Smith f97782724e fix(maps): exclude stadia key from localStorage cache
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 22:11:34 +01:00
James Smith e4df3eaecb feat(maps): add Stadia dark + tactical tile providers with API key support
- Add offline.stadia_key to OFFLINE_DEFAULTS in routes/offline.py
- Add stadia_dark and tactical tile providers to Settings.tileProviders
- Update getTileConfig() to inject Stadia API key or fall back to CartoDB dark
- Add setStadiaKey() method for saving and applying the API key
- Show/hide Stadia key row in setTileProvider() and _updateUI()
- Add Stadia options to tile provider select in settings modal
- Add Stadia API key input row to settings modal
- Add TDD tests for stadia_key backend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 22:04:07 +01:00
James Smith 16b95e4804 test: add system group to nav state coverage 2026-04-13 21:10:14 +01:00
James Smith f1a029262b feat: persist nav group open/closed state to localStorage
Adds initNavGroupState() and saveNavGroupState() functions so the
open/closed state of each .mode-nav-dropdown survives page reloads.
Active groups are never force-closed even if localStorage says closed.
Adds test_nav_state.py with two tests verifying presence of the
functions and data-group attributes on all five nav groups.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:08:27 +01:00
James Smith 51c10144c7 style: add card vignette and scanline texture to visuals containers 2026-04-13 21:05:08 +01:00
James Smith b5ae7fe472 style: add pulse animation to active panel indicators 2026-04-13 21:03:08 +01:00
James Smith b75d28f284 style: fix padding compensation and light theme border on nav active states 2026-04-13 21:01:34 +01:00
James Smith 4a3a7127ca style: nav active state → left-border cyan glow, hover → glow bg 2026-04-13 18:18:38 +01:00
James Smith bfff092657 style: group --accent-cyan-glow with other cyan tokens 2026-04-13 18:17:42 +01:00
James Smith f2f17ac26e style: deepen background tokens and add scanline/glow variables 2026-04-13 18:16:03 +01:00
James Smith 8c61af2863 docs: add Sub-Project 1 implementation plan (design system uplift)
6-task plan covering token deepening, nav active state glow, panel pulse
animation, scanline texture, and localStorage nav group persistence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:50:22 +01:00
James Smith 34fb030af1 docs: add UI/UX improvements design spec
Mission Control aesthetic, maps overhaul, mode polish sprint for 12 modes,
and 5 new features (Spectrum Overview, Alerts Engine, Signal Recording,
Signal ID, Mobile PWA) — decomposed into 4 sequential sub-projects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:47:30 +01:00
James Smith 238ad7936a chore: add pre-commit hook to catch lint errors before push
Adds ruff pre-commit hook that auto-fixes and formats on every commit,
preventing lint CI failures from reaching GitHub.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 13:40:22 +01:00
Smittix b01598753d fix(adsb): disable bias-T on stop and warn when toggled while running (#207)
* fix(adsb): disable bias-T on stop and warn when toggled while running

The RTL-SDR bias-T hardware register persists after the device is closed,
so toggling bias-T off in the UI and stopping the SDR had no effect on the
actual hardware — verified with a multimeter in issue #205.

- Add disable_bias_t_via_rtl_biast() to rtlsdr.py (mirrors enable, uses -b 0)
- Track adsb_bias_t_active in adsb.py; call disable on stop_adsb() so the
  hardware register is cleared when ADS-B is stopped
- Show an inline warning in the UI when the bias-T checkbox is toggled while
  any SDR mode is active, since the setting only takes effect at start time

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(lint): remove unused imports in tscm sweep.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 13:18:50 +01:00
Smittix 3fedff9d08 Merge pull request #206 from smittix/fix/sensor-removeChild-freeze
fix(sensor): replace static NodeList loops causing page freeze and removeChild TypeError
2026-04-12 21:29:41 +01:00
James Smith 1fc80b05b1 fix(sensor): replace static NodeList while-loops causing page freeze and removeChild TypeError
Four list-trimming loops used querySelectorAll (static NodeList) inside a
while condition, so .length never decreased — causing infinite loops that
froze the page, or repeated removeChild calls on already-removed nodes
(TypeError: parameter 1 is not of type 'Node').

Also replaces blocking alert() with showInfo() for start errors and adds
a .catch() handler to the start_sensor fetch so network failures surface
cleanly instead of leaving the UI in a broken state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:28:50 +01:00
James Smith 0210791c69 feat(export): AIS UDP NMEA forward and JSON export endpoints for AIS/ADS-B
AIS:
- New optional NMEA UDP forwarding via AIS-catcher's -u flag, configurable
  from the AIS sidebar (host + port). Lets OpenCPN and other NMEA tools
  receive live vessel data directly. All SDR builders updated.
- New GET /ais/vessels endpoint — clean JSON snapshot of tracked vessels
  for REST integration

ADS-B:
- New GET /adsb/aircraft endpoint — JSON snapshot of all tracked aircraft,
  with optional ?icao= and ?military=true filters. Response includes a
  reminder that port 30003 (SBS) is already available for tools like
  Virtual Radar Server and OpenCPN's AIS/target plugin.

Closes #90
2026-04-05 16:20:10 +01:00
James Smith 592e97719b feat(gain): normalize gain controls across modes
- Pager and sensor gain inputs changed from unvalidated text fields to
  number inputs with min/max/step constraints
- ADS-B dashboard now exposes a gain input in the tracking strip;
  previously gain was hardcoded to 40 dB with no user control
- validate_gain() ceiling raised from 50 to 102 dB to support HackRF
  (LNA 40 + VGA 62 = 102 dB combined) and LimeSDR (73 dB)
- sdrCapabilities gain_max values corrected: HackRF 62→102, Airspy 21→45
- onSDRTypeChanged() now propagates gain_max to all mode gain inputs so
  HTML constraints match the selected SDR's actual range

Closes #162
2026-04-05 16:02:03 +01:00
James Smith ea80b5ebc3 feat(tscm): add custom frequency range option to RF sweep
Adds a "Custom Range" sweep type that lets users specify start/end MHz
instead of using a fixed preset. Useful in dense RF environments where
a full or standard sweep returns too many signals and causes slowdown.

UI shows start/end MHz inputs when "Custom Range" is selected. Range is
validated (0 < start < end ≤ 6000 MHz) before the sweep starts.
Backend threads the ranges through to _scan_rf_signals(), which already
supports arbitrary frequency bands.

Closes #172
2026-04-05 15:46:01 +01:00
James Smith fe64dd9c93 feat(api): add sdr_claims to /health and surface /devices/status
/health now includes sdr_claims: a dict mapping 'sdr_type:device_index'
to the mode currently using that device (e.g. {"rtlsdr:0": "pager"}).
Empty when no devices are in use.

/devices/status already existed and returns the full device list with
in_use/used_by per device — documented in the issue response.

Closes #158
2026-04-05 14:40:42 +01:00
James Smith f0fb97512a feat(adsb): expand aircraft icon types and add type to hover tooltip
Adds three new icon shapes (widebody, bizjet, turboprop) to the existing
set (jet, prop, helicopter, military, glider), giving 8 distinct silhouettes.
Classification covers common ICAO type codes: widebodies (744, 777, A380 etc.),
business jets (Citation, Gulfstream, Learjet etc.), turboprops (ATR, DH8 etc.),
and light GA piston aircraft.

Hover tooltip now shows aircraft type description (e.g. "Airbus A320-200")
when available from the aircraft DB, in addition to callsign and altitude.

Closes #201
2026-04-05 14:29:50 +01:00
James Smith 6ea34a4c60 fix(weather-sat): lower default gain to 30 dB to prevent ADC saturation
Strong passes at 40 dB (the previous default) cause RTL-SDR ADC clipping,
producing a distorted IQ stream that SatDump cannot lock onto. 30 dB is
a safer starting point that still captures weak passes cleanly.

Also adds a UI hint below the gain control explaining the saturation issue.

Closes #185
2026-04-05 14:20:06 +01:00
James Smith 6572119360 fix(bluetooth): fix locate button not switching to bt_locate mode
Remove the split fast-path in doLocateHandoff that called BtLocate.handoff()
directly when the module was already loaded. That path relied on handoff()
internally calling switchMode, causing a double switchMode in the lazy-load
path and no guaranteed mode switch in the fast path.

Now doLocateHandoff always calls switchMode('bt_locate') first (lazy-loading
script/styles as needed), then calls BtLocate.handoff() in .then(). Removed
the redundant switchMode call from BtLocate.handoff() since the caller owns
the mode transition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 13:54:16 +01:00
James Smith efb7d0ed20 chore: add AGENTS.md and superpowers plan docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:59:51 +01:00
James Smith 5b9d81e3a8 fix(bluetooth): add transform-box to radar sweep for Firefox, remove dead radar-sweep rule 2026-03-29 21:34:35 +01:00
James Smith 71e5599300 feat(bluetooth): WiFi-style 2-line device rows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:07:51 +01:00
James Smith 6967a44620 feat(bluetooth): scan indicator JS, sort controls, renderAllDevices
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:31:02 +01:00
James Smith ab4745c70a fix(bluetooth): update filter container ID to btFilterGroup, document sticky offset 2026-03-29 15:29:07 +01:00
James Smith d2c00b4b2c feat(bluetooth): scan indicator and sort+filter controls row in device list header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:25:15 +01:00
James Smith d45b8bc2fb feat(bluetooth): CSS animated radar sweep with trailing glow arc
Replaces the requestAnimationFrame loop in proximity-radar.js with a
CSS @keyframes rotation on .bt-radar-sweep, mirroring the WiFi radar
pattern. Adds two trailing arc paths for a glow effect and updates
setPaused() to toggle animationPlayState instead of the rAF flag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 14:35:45 +01:00
James Smith 2511227c4e Add Bluetooth UI polish implementation plan 2026-03-27 16:48:08 +00:00
James Smith 5ee60c5259 Add Bluetooth UI polish design spec 2026-03-27 16:19:16 +00:00
James Smith 7a4dbb8260 fix(wifi): remove dead chart pendingRender flag, dead radar highlight call, CSS.escape client mac
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:58:58 +00:00
James Smith 73b227c49b feat(wifi): network detail panel replaces slide-up drawer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:48:23 +00:00
James Smith bfbf06f5c5 fix(wifi): render heatmap and security ring even when filter yields no networks 2026-03-26 22:40:08 +00:00
James Smith e5a0635418 feat(wifi): channel heatmap and security ring chart
Replace static channel bar chart and security dots with a scrolling
2.4 GHz channel heatmap (up to 10 scan snapshots) and an SVG donut
security ring showing WPA2/WPA3/WEP/Open network distribution.
2026-03-26 22:38:31 +00:00
James Smith 2fce80677a fix(wifi): correct zone count colors (close=red, far=green) 2026-03-26 22:32:34 +00:00
James Smith 56ebdd7670 fix(wifi): radar bssidToAngle divisor, Firefox SVG transform-origin, zone label clarity
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:31:21 +00:00
James Smith 4c37d39e07 fix(wifi): remove duplicate zone count update from updateStats 2026-03-26 22:28:44 +00:00
James Smith d1d44195c1 feat(wifi): animated SVG proximity radar with sweep rotation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:26:47 +00:00
James Smith 0dbcb175c0 fix(wifi): XSS fix for onclick handler, unknown security badge, null rssi handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:23:40 +00:00
James Smith ea348b3360 feat(wifi): replace table with styled div network rows
Replaces the 7-column <table> network list with flex div rows featuring
two-line layout (SSID + security badges on top, signal bar + meta on
bottom), coloured left-border threat indicators, and new sort controls.
Renames selectedNetwork → selectedBssid and updateNetworkTable → renderNetworks throughout wifi.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:18:51 +00:00
James Smith 36399cf4aa feat(wifi): enhanced status bar with open count and scan indicator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:11:48 +00:00
James Smith 837090d150 Finalise WiFi scanner redesign spec (reviewer approved) 2026-03-26 21:54:13 +00:00
James Smith d01cb4b6f3 Add WiFi scanner redesign spec 2026-03-26 21:40:27 +00:00
mitchross 3aadaf1c86 Fix clock flickering on main page — inline updateHeaderClock was using UTC
The updateHeaderClock function in index.html was inlined and still using
raw UTC (toISOString), while nav.html's version used InterceptTime.
Both ran on 1-second intervals updating the same element, causing the
clock to rapidly alternate between ET and UTC.

Fix: Updated the inline version in index.html to use InterceptTime,
matching nav.html. Added _navClockStarted guard and onChange listener
so only one interval runs and timezone changes apply instantly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 01:15:28 -04:00
mitchross 6de443e833 Fix clock flickering from duplicate setInterval timers
app.js and nav.html both started 1-second intervals updating the same
#headerUtcTime element. Even though both used InterceptTime, their
slightly different timing caused visible text flicker.

Fix: app.js now sets window._navClockStarted before starting its
interval, so nav.html's guard condition skips its duplicate. Also
register InterceptTime.onChange listener in app.js for instant updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 00:29:06 -04:00
mitchross f4672cf0c7 Fix global timezone on ADS-B dashboard and harden VDL2 correlation
Timezone fixes:
- Add utils.js (InterceptTime) to adsb_dashboard.html — was completely
  missing, causing all times to fall back to UTC regardless of setting
- Register onChange listener in nav.html so clock updates instantly
  when timezone/format is changed in Settings
- Initialize timezone/format dropdowns on ADS-B dashboard page load
- Browser-verified: ET/12h ↔ UTC/24h switches instantly on ADS-B page

VDL2 correlation fix:
- Force ICAO hex to uppercase when promoting from VDL2 src.addr (dumpvdl2
  may output lowercase, ADS-B stores uppercase — case mismatch prevented
  correlator from matching)
- Move ICAO/addr promotion before ACARS field extraction so even
  non-ACARS VDL2 frames (XID, connection mgmt) get correlated

Auth:
- Add INTERCEPT_DISABLE_AUTH env var to skip login for local/dev use
- Configurable via docker-compose.yml environment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 00:05:49 -04:00
mitchross b66ac935b7 Fix VDL2 messages not appearing in aircraft datalink panel
Root cause: dumpvdl2 outputs nested JSON (vdl2.avlc.acars.flight) but
FlightCorrelator only checks top-level fields. VDL2 messages were stored
in the correlator but never matched to any aircraft.

Fix: Promote identifying fields (flight, reg, tail, icao, addr, label,
text) from the nested VDL2 structure to top-level before storing in the
correlator. Also promote AVLC source address as ICAO when src.type is
"Aircraft".

Also fix VDL2 sidebar timestamps to use global InterceptTime setting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:43:15 -04:00
mitchross 7d704c9d42 Fix ACARS message display and add missing decoded data types
- Use global InterceptTime for all ACARS timestamps (respects Eastern/12h)
- Add weather message rendering (wind, temperature, turbulence)
- Add CPDLC controller-pilot message rendering (purple highlight)
- Add squawk code change rendering (red highlight)
- Fix engine_data crash when parsed value isn't an object
- Show tail/registration alongside flight number on all cards
- Increase message text truncation to 200 chars
- Add FL prefix to flight level in position reports
- Applied consistently across ADS-B dashboard, sidebar feed, and standalone ACARS mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:36:32 -04:00
mitchross ebc838fa9d Add global timezone/12h-24h setting and improve satellite selection
Global time preferences (Settings > Display > Time & Timezone):
- InterceptTime utility in core/utils.js with timezone + 12h/24h support
- Timezone options: UTC, Local, Eastern, Central, Mountain, Pacific
- Time format: 12-hour (AM/PM) or 24-hour toggle
- Defaults to US/Eastern + 12-hour
- Header nav clock updates to use selected timezone and format
- Weather satellite mode delegates to global InterceptTime
- Settings persist via localStorage, change listeners notify all modes

Weather satellite improvements:
- Satellite dropdown defaults to "All Meteor Satellites" showing all passes
- Can still filter to specific satellite (M2-3, M2-4, M2-4-80K)
- Capture button on pass cards auto-selects the correct satellite

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:24:42 -04:00
mitchross 1e5bc0054d Enhance weather satellite UX with pass geometry, guides, and wider predictions
Pass prediction improvements:
- Widen prediction window to 48h at 5° min elevation (was 24h/15°)
- Add AOS/TCA/LOS pass geometry detail panel with times and bearings
- Fix duration display (was showing seconds labeled as minutes)
- Enhanced pass cards with AOS/LOS times, bearings, and directions
- Add REFRESH button in passes panel header
- Better empty state with clear "set your location" prompt and icon

Countdown and visual:
- Pulse animation on countdown when pass is imminent or active
- Countdown numbers scale up and change color for urgency

Sidebar getting started guide:
- New "Getting Started" section explaining what Meteor satellites are,
  polar orbits, 4-8 passes/day, step-by-step workflow
- "When to look" tips (elevation, day vs night, pass direction)
- "What you need" equipment table with costs
- Collapsed antenna guide by default to reduce initial overwhelm
- Improved offline decode section with clear instructions on where
  to get IQ recordings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:13:37 -04:00
mitchross 43fb735e4e Fix Meteor LRPT decoding in Docker and enhance weather satellite UI
Docker fixes:
- Add missing COPY for /usr/local/share/ (pipeline definitions were never
  reaching the runtime image — root cause of silent SatDump failures)
- Add libfftw3-double3 and libfftw3-single3 runtime dependencies
- Handle arm64 vs x86 install path differences (/usr vs /usr/local)
- Split SatDump compile and staging into separate layers for better caching
- Add build-time assertions to catch missing pipelines early

UI enhancements:
- Timezone selector (UTC, Local, Eastern, Central, Mountain, Pacific)
  with localStorage persistence — all time displays update instantly
- Pass analysis bar showing 24h quality breakdown and best upcoming pass
- Enhanced pass cards with cardinal direction (NW→SE), BEST badge
- Console timestamps, log level filters (ALL/SIGNAL/PROG/ERR), COPY/CLR
- Pass count in stats strip
- Demo data mode for UI testing without SDR or live satellite pass
- Meteor M2-4 80k baud fallback pipeline option

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:05:31 -04:00
James Smith 1dde2a008e Bump VERSION to 2.26.13 and add changelog entry
config.VERSION was not updated when the v2.26.13 tag was created,
causing the update checker to always report an update available on
fresh installs and git pulls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 17:15:32 +00:00
James Smith af2ab567ca Persist aircraft DB under data/adsb/ for Docker volume compatibility
Move aircraft_db.json and aircraft_db_meta.json from the project root
to data/adsb/ so they survive container restarts and rebuilds. Add
matching volume mount to both Docker Compose profiles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 16:56:34 +00:00
James Smith 6928b8a622 Fix Docker volume mount shadowing data Python modules
Mounting ./data:/app/data caused the host directory to shadow the
entire /app/data Python package, making modules like data.oui
unavailable and crashing gunicorn on startup. Mount only the three
runtime output subdirectories instead.

Fixes #200

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 16:28:11 +00:00
James Smith 205f396942 Fix TSCM sweep module variable scoping and remove stale progress bar call
- Access module-level _sweep_running, _current_sweep_id, and tscm_queue
  via explicit package import to avoid UnboundLocalError from closure
  variable shadowing in route handlers
- Remove orphaned tscmProgressBar.style.width assignment in index.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:52:29 +00:00
James Smith 89c7c2fb07 Fix 5GHz WiFi scanning failures in deep scan and band detection
- Fix deep scan with 'All bands' never scanning 5GHz: band='all' now
  correctly passes --band abg to airodump-ng (previously no flag was
  added, causing airodump-ng to default to 2.4GHz-only)
- Fix APs first seen without channel info permanently stuck at
  band='unknown': _update_access_point now backfills channel, frequency,
  and band when a subsequent observation resolves the channel
- Fix legacy /wifi/scan/start combining mutually exclusive --band and -c
  flags: --band is now only added when no explicit channel list is given,
  and the interface is always placed as the last argument

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:50:47 +00:00
James Smith b20b9838d0 Fix ADS-B remote mode incorrectly stopping other SDR services
When using a remote SBS feed, no local SDR is needed. The pre-flight
device conflict check was running regardless and stopping whichever
mode had the selected SDR device claimed — even though ADS-B remote
mode never touches a local SDR. Skip the conflict check when remoteConfig is set.
2026-03-20 14:04:04 +00:00
James Smith 2d65c4efbf Fix radiosonde false 'missing' report at end of setup
check_tools() was using cmd_exists on 'auto_rx.py' which fails because
it's never in PATH — installed to /opt/radiosonde_auto_rx/. Now uses
the same file-based check as tool_is_installed(), consistent with
health check and status view.
2026-03-20 13:53:50 +00:00
James Smith 34e1d25069 Fix ruff lint errors to unblock CI (import sorting, unused imports, style) 2026-03-20 13:51:30 +00:00
James Smith 90d39f12c1 Add multi-arch support (amd64 + arm64) to Docker CI workflow 2026-03-20 13:47:27 +00:00
Smittix bca7888077 Merge pull request #199 from jangrewe/main
Add Github Action to build Docker image
2026-03-20 13:38:32 +00:00
Jan Grewe cbc6275307 Add Github Action to build Docker image 2026-03-20 13:58:02 +01:00
James Smith b26ce4f56f Balance column heights to eliminate sky view gap
Precise calculation showed mission-drawer (~1100px) was 213px taller
than command-rail content (~887px), leaving 213px of empty background
at the bottom of the right column.

Three targeted reductions to mission-drawer height (~234px total):
- drawer-actions: stacked 1-column → 3-column row (-76px)
- drawer-list: max-height 240px → 180px (-40px)
- drawer-info-grid: 1-column → 2-column for Quick Info (-118px)

Mission-drawer drops to ~866px, command-rail (~887px) now drives the
primary-layout row height — gap closes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 00:08:07 +00:00
James Smith 44428c2517 Fix sky view gap: don't stretch polar panel beyond its content
The command-rail's 1fr last row caused the sky view panel to fill all
remaining column height (driven by the tall mission-drawer), showing a
large empty bordered space below the pass data strip.

Switch to all-auto rows with align-content: start so each panel is
exactly as tall as its content — the open background below the column
looks intentional rather than a panel with dead space inside it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 00:00:47 +00:00
James Smith a670103325 Reduce map and primary-layout min-height 460px → 400px to close sky view gap
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:56:19 +00:00
James Smith a2bd0e27f9 Fix weather sat handoff: remove defunct METEOR-M2, fire change event
- Remove 'METEOR-M2' (NORAD 40069) from WEATHER_SAT_KEYS — it has no
  entry in WEATHER_SATELLITES and no dropdown option, so the Capture
  button was silently scheduling against the wrong satellite
- Dispatch a 'change' event after setting satSelect.value in preSelect()
  and startPass() so any UI listeners (frequency display, mode info)
  update correctly when the satellite is set programmatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:52:14 +00:00
James Smith 7ca018fd7b Reduce map min-height 520px → 460px to close gap below sky view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:45:41 +00:00
James Smith 607a2f28fa Cap polar container height to prevent oversized sky view on narrow screens
1fr in the grid row caused the panel to fill the entire remaining page
height on mobile (~1000px+), leaving large gaps around the centred content.
max-height: min(55vh, 520px) keeps it proportionate on any screen size.
Also switch to justify-content: flex-start so the canvas+strip pack at
the top rather than floating in the middle of a large void.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:43:04 +00:00
James Smith a42ea35d8b Add AOS/TCA/LOS pass data strip below polar plot
Fills the empty space below the sky view circle with a compact
three-column AOS / TCA / LOS readout (time + azimuth/elevation)
and a duration + max elevation footer line.
Populated by drawPolarPlot() when a pass is selected; shows a
placeholder prompt otherwise.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:40:38 +00:00
James Smith 123d38d295 Fix oval polar plot — remove height:100% that overrode aspect-ratio
Setting both width:100% and height:100% made CSS ignore aspect-ratio,
stretching the drawing buffer non-uniformly into the tall container.
Fixed by keeping only width:100% + max-height:100% so aspect-ratio:1/1
clamps the height and the element stays square.
Draw functions now use canvas.offsetWidth for the square buffer size.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:35:34 +00:00
James Smith 35c874da52 Stretch sky view to fill right column height
- command-rail last row changed from minmax(260px, 340px) to 1fr so the
  polar plot expands to fill whatever vertical space remains after the
  Next Pass and Live Telemetry panels
- polar-container made flex-column so panel-content can grow with flex: 1
- #polarPlot width/height 100% with aspect-ratio 1/1 — canvas fills the
  available square area and stays proportional
- Remove align-items: start from the 1320px breakpoint primary-layout so
  the command-rail stretches to match map height in the two-column layout
- Fix matching 2-column command-rail rows at 1320px breakpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:33:17 +00:00
James Smith ad4a4db160 Fix squashed sky view polar plot and eliminate wasted space
- Remove align-self: start from .polar-container so the grid row's
  minmax(260px, 340px) height is actually respected
- Switch #polarPlot to aspect-ratio: 1/1 so the canvas is always square
- Fix both draw functions to size canvas from getBoundingClientRect on
  the canvas itself (not parent) using min(width, height) for a square plot
- Remove min-height from .dashboard to prevent empty space below content
  on narrow/mobile screens where stacked panels are shorter than 720px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:17:35 +00:00
James Smith 72d4fab25e Fix pass calculation race condition and 1Hz distance updates
- Move _passAbortController = null to after response.json() so the retry
  scheduler cannot see a false idle state mid-parse, increment
  _passRequestId, and discard the in-flight response — this was causing
  non-ISS satellites to show no passes intermittently
- Add _computeSlantRange() helper using 3D ECEF geometry
- Update applyTelemetryPosition to compute slant range from SSE lat/lon/
  altitude, giving distance updates at 1Hz instead of 5s HTTP poll rate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:55:56 +00:00
James Smith 7c4342e560 Fix satellite tracker TLE key mismatch and empty pass caching
Two root-cause bugs causing the reported issues:

1. Tracker never sent ISS positions: _start_satellite_tracker fell back
   to sat_name.replace(' ', '-').upper() as the TLE cache key when the
   DB entry had null TLE lines. For 'ISS (ZARYA)' this produced
   'ISS-(ZARYA)' which has no matching entry in _tle_cache (keyed as
   'ISS'). ISS was silently skipped every loop tick, so no SSE positions
   were ever emitted and the map marker never moved.

   Fix: try _BUILTIN_NORAD_TO_KEY.get(norad_int) first before the
   name-derived fallback so the NORAD-to-key mapping is always used.

2. Stale TLE pass prediction results were cached: if startup TLEs were
   too old for Skyfield to find events in the 48h window, the empty
   passes list was cached for 300s. A page refresh within that window
   re-served the empty result, showing 'NO PASSES FOUND' persistently.

   Fix: only cache non-empty pass results so the next request
   recomputes once the TLE auto-refresh has populated fresh data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:21:31 +00:00
James Smith 33959403f4 fix(satellite): show 'NO UPCOMING PASSES' when all passes are in the past
updateCountdown fell back to passes[0] even when it was in the past,
showing 00:00:00:00 with a stale satellite name indefinitely. Now
displays a clear 'NO UPCOMING PASSES' state with '--' for all fields
when no future pass exists in the current prediction window.
2026-03-19 21:55:25 +00:00
James Smith f549957c0b perf(satellite): compute ground tracks in thread pool, not inline
Ground track computation (90 Skyfield points per satellite) was blocking
the 1Hz tracker loop on every cache miss. On cold start with multiple
tracked satellites this could stall the SSE stream for several seconds.

Tracks are now computed in a 2-worker ThreadPoolExecutor. The tracker
loop emits position without groundTrack on cache miss; clients retain
the previous track via SSE merge until the new one is ready.
2026-03-19 21:55:00 +00:00
James Smith e5abeba11c refactor(satellite): simplify telemetry abort controller management
Consolidated to a single active-request guard with cleanup in finally.
The previous pattern had redundant null-checks across try and catch, and
an always-false check on a controller that was already null. Cancel-on-
new-request is now explicit before creating the new controller.
2026-03-19 21:49:45 +00:00
James Smith 8cf1b05042 fix(satellite): add METEOR-M2 to weather satellite handoff keys
METEOR-M2 (NORAD 40069) is a weather satellite with LRPT downlink but
was missing from WEATHER_SAT_KEYS, so no capture button appeared in
the pass list. Adds it alongside M2-3 and M2-4.
2026-03-19 21:49:09 +00:00
James Smith cfcdc8e85e fix(satellite): use wgs84 subpoint elevation for altitude in /position
Replace geocentric.distance().km - 6371 (fixed spherical radius) with
wgs84.subpoint(geocentric).elevation.km in the /position endpoint.
The SSE tracker was already fixed in the Task 1 commit.
2026-03-19 21:48:55 +00:00
James Smith d240ae06e3 fix(satellite): populate currentPos with full telemetry in pass predictions
Previously currentPos only had lat/lon, so the updateTelemetry fallback
(used before first live position arrives) always showed '---' for
altitude/elevation/azimuth/distance. currentPos now includes all fields
computed from the request observer location. updateTelemetry simplified
to delegate to applyTelemetryPosition.
2026-03-19 21:48:33 +00:00
James Smith d84237dbb4 feat(satellite): add 24-hour periodic TLE auto-refresh
TLE data was only refreshed once at startup. After each refresh, a new
24-hour timer is now scheduled in a finally block so it fires even on
refresh failure. threading moved to module-level import.
2026-03-19 21:47:38 +00:00
James Smith 7194422c0e fix(satellite): SSE path only updates orbit position, not observer data
Adds a 'source' param to handleLivePositions. The SSE path ('sse') only
applies lat/lon/altitude/groundTrack since the server-side tracker has
no per-client location. The HTTP poll path ('poll') owns all observer-
relative data and the visible-count badge.
2026-03-19 21:46:36 +00:00
James Smith d20808fb35 fix(satellite): strip observer-relative fields from SSE tracker
SSE runs server-wide with DEFAULT_LAT/LON defaults of 0,0. Emitting
elevation/azimuth/distance/visible from the tracker produced wrong
values (always visible:False) that overwrote correct data from the
per-client HTTP poll every second.

The HTTP poll (/satellite/position) owns all observer-relative data.
SSE now only emits lat/lon/altitude/groundTrack. Also removes the
unused DEFAULT_LATITUDE/DEFAULT_LONGITUDE import.
2026-03-19 21:45:48 +00:00
James Smith 51b332f4cf Stabilize satellite live telemetry state 2026-03-19 21:09:03 +00:00
James Smith a8f73f9a73 Tear down satellite dashboard cleanly 2026-03-19 20:34:32 +00:00
James Smith 4798652ad5 Preserve satellite panes during refresh 2026-03-19 20:30:52 +00:00
James Smith 080464de98 Guard satellite target refreshes 2026-03-19 19:12:51 +00:00
James Smith 8caec74c5c Stabilize satellite target switching 2026-03-19 17:44:02 +00:00
James Smith 511cecb311 Speed up satellite live telemetry updates 2026-03-19 17:18:39 +00:00
James Smith 0992d6578c Batch satellite pass predictions 2026-03-19 17:07:23 +00:00
James Smith 3f1564817c Stabilize satellite pass target switching 2026-03-19 16:41:55 +00:00
James Smith b62b97ab57 Wire satellite capture handoff 2026-03-19 15:59:58 +00:00
James Smith 2eeea3b74d Harden satellite target switching 2026-03-19 15:33:20 +00:00
James Smith f05a5197cd Fix satellite target switching regression 2026-03-19 14:55:48 +00:00
James Smith 016d05f082 Stabilize satellite dashboard refreshes 2026-03-19 14:26:08 +00:00
James Smith 302a362885 Tighten satellite polar plot sizing 2026-03-19 14:16:44 +00:00
James Smith 81c05859fc Fix satellite dashboard startup helpers 2026-03-19 13:49:20 +00:00
James Smith f1881fdf52 Stabilize satellite dashboard startup 2026-03-19 13:23:52 +00:00
James Smith d0731120f9 Restore satellite mission controls 2026-03-19 13:06:26 +00:00
James Smith 7677b12f74 Move satellite packets into map console 2026-03-19 12:17:28 +00:00
James Smith ddaf5aa64e Rework satellite dashboard mission layout 2026-03-19 12:01:59 +00:00
James Smith 2418ae2d8b Fix satellite pass prediction route 2026-03-19 11:39:22 +00:00
James Smith 0916b62bfe Cache satellite pass predictions 2026-03-19 11:27:38 +00:00
James Smith 0b22393395 Vendor flask-socketio fallback for radiosonde 2026-03-19 11:16:42 +00:00
James Smith 9fa492e20c Vendor semver fallback for radiosonde 2026-03-19 11:09:54 +00:00
James Smith fa46483dd9 Probe more radiosonde Python environments 2026-03-19 11:02:42 +00:00
James Smith 18b442eb21 Fix dashboard startup regressions and mode utilities 2026-03-19 10:37:21 +00:00
James Smith 5f34d20287 Delay welcome page GPS and voice streams 2026-03-19 09:34:33 +00:00
James Smith 5905aa6415 Defer hidden dashboard startup work 2026-03-19 09:19:36 +00:00
James Smith aaed831420 Lazy-load satellite iframe on main dashboard 2026-03-19 09:05:48 +00:00
James Smith 007a8d50c6 Revert "Retry ADS-B map bootstrap safely"
This reverts commit 02ce4d5bb6.
2026-03-19 08:54:59 +00:00
James Smith 02ce4d5bb6 Retry ADS-B map bootstrap safely 2026-03-19 08:52:08 +00:00
James Smith 613258c3a2 Retry slow SDR detection in ADS-B 2026-03-19 08:43:20 +00:00
James Smith 4410aa2433 Harden ADS-B dashboard bootstrap 2026-03-19 08:35:43 +00:00
James Smith 54ad3b9362 Revert "Keep ADS-B on local startup tiles"
This reverts commit 2cf2c6af2a.
2026-03-19 08:32:10 +00:00
James Smith 2cf2c6af2a Keep ADS-B on local startup tiles 2026-03-19 08:28:13 +00:00
James Smith f5f3e766ad Keep ADS-B fallback grid until tiles load 2026-03-19 08:18:00 +00:00
James Smith fb8b6a01e8 Shorten agent health checks on load 2026-03-19 08:09:07 +00:00
James Smith db0a26cd64 Ignore aborted satellite pass requests 2026-03-19 08:04:36 +00:00
James Smith 8b1ca5ab96 Defer noncritical ADS-B startup work 2026-03-19 08:01:33 +00:00
James Smith cb0fb4f3be Reduce repeated ADS-B device probes 2026-03-19 07:55:19 +00:00
James Smith 334146b799 Skip pre-stop on dashboard navigation 2026-03-19 07:47:46 +00:00
James Smith 63237b9534 Stop clearing browser caches on load 2026-03-19 07:43:32 +00:00
James Smith 595a2003d5 Revert "Reduce ADS-B map layout shift"
This reverts commit 3afaa6e1ee.
2026-03-19 00:17:22 +00:00
James Smith 3afaa6e1ee Reduce ADS-B map layout shift 2026-03-19 00:11:29 +00:00
James Smith 5731631ebc Harden APRS mode teardown and map fallback 2026-03-19 00:06:47 +00:00
James Smith ac445184b6 Disable stale dashboard service worker cache 2026-03-19 00:01:47 +00:00
James Smith 981b103b90 Revert "Stage dashboard startup requests"
This reverts commit af7b29b6b0.
2026-03-18 23:57:37 +00:00
James Smith af7b29b6b0 Stage dashboard startup requests 2026-03-18 23:53:54 +00:00
James Smith 0ff0df632b Open satellite dashboard in new tab 2026-03-18 23:41:29 +00:00
James Smith 73e17e8509 Use direct satellite dashboard links 2026-03-18 23:35:24 +00:00
James Smith 317e0d7108 Fix satellite mode redirect endpoint 2026-03-18 23:32:38 +00:00
James Smith dd37a0b5a7 Unify satellite navigation to dashboard 2026-03-18 23:29:09 +00:00
James Smith 28f172a643 Lock satellite sidebar panel heights 2026-03-18 23:20:53 +00:00
James Smith 96146a2e2c Stabilize satellite dashboard sidebar panels 2026-03-18 23:17:02 +00:00
James Smith e32942fb35 Refresh embedded satellite dashboard state 2026-03-18 23:09:07 +00:00
James Smith a61d4331f0 Harden embedded satellite dashboard loading 2026-03-18 23:00:21 +00:00
James Smith 62ee2252a3 Fix satellite dashboard refresh flows 2026-03-18 22:53:36 +00:00
James Smith 6fd5098b89 Clear stale telemetry and add transmitter fallbacks 2026-03-18 22:43:40 +00:00
James Smith 6941e704cd Include NORAD IDs in satellite positions 2026-03-18 22:40:00 +00:00
James Smith 985c8a155a Harden satellite dashboard telemetry loading 2026-03-18 22:35:31 +00:00
James Smith d0402f4746 Refresh weather decoder docs for Meteor flow 2026-03-18 22:30:54 +00:00
James Smith 6dc0936d6d Align Meteor tracking defaults 2026-03-18 22:28:04 +00:00
James Smith 38a10cb0de Improve persisted Meteor status in dashboard 2026-03-18 22:25:20 +00:00
James Smith badf587be6 Reset persisted Meteor state on satellite switch 2026-03-18 22:24:12 +00:00
James Smith a995fceb8c Clarify persisted Meteor decode failures 2026-03-18 22:23:09 +00:00
James Smith 2a9c98a83d Show persisted Meteor decode state in weather mode 2026-03-18 22:22:07 +00:00
James Smith 4cf394f92e Persist Meteor decode job state 2026-03-18 22:20:24 +00:00
James Smith e388baa464 Add Meteor LRPT ground station pipeline 2026-03-18 22:01:52 +00:00
James Smith 5cae753e0d Harden dashboard loading against network stalls 2026-03-18 21:33:32 +00:00
James Smith 86625cf3ec Fix mode switch re-entry regressions 2026-03-18 21:26:01 +00:00
James Smith 98bb6ce10b Fix flask-sock install failure on RPi by skipping --only-binary for pure-Python packages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 20:59:00 +00:00
James Smith cbe7f591e3 Fix weather sat test failures: login auth, timer refs, and SDR device mocking
- Add authenticated client fixture to test_weather_sat_routes.py so
  require_login() before_request doesn't redirect test clients to /login
- Save timer mock references before disable()/skip_pass() clear _timer = None
- Patch app.claim_sdr_device to return None in execute_capture and
  scheduling cycle tests to avoid real USB hardware probing in CI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 20:56:01 +00:00
James Smith 0078d539de Fix flask-sock ImportError caused by simple-websocket missing from venv
When pip installs flask-sock into the venv, it finds simple-websocket
already satisfied in ~/.local (user site-packages from a prior install)
and skips installing it into the venv. The venv Python cannot import from
~/.local (user site-packages are disabled in venvs), so flask_sock's
top-level "from simple_websocket import Server" raises ImportError, and
all WebSocket features are silently disabled.

Fix: explicitly list simple-websocket>=0.5.1 as an install target in
setup.sh and requirements.txt so pip installs it into the venv
regardless of what is already present in user or system site-packages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 20:44:52 +00:00
James Smith e1b532d48a Fix SSE first-byte delay in ADS-B and controller streams; harden WebSocket init
- Add immediate keepalive to /adsb/stream generator so the Werkzeug dev
  server flushes response headers immediately on tracking start, preventing
  the 30-second delay before the aircraft map begins receiving data
- Same fix for /controller/stream/all used by the ADSB dashboard in agent mode
- Widen WebSocket init exception guards in app.py from ImportError to
  Exception so any startup failure (e.g. RuntimeError from flask-sock on
  an unsupported WSGI server) is caught instead of propagating

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 20:40:15 +00:00
James Smith f043baed9f Fix satellite dashboard page never loading by sending immediate SSE keepalive
Werkzeug's dev server buffers SSE response headers until the first body byte
is written. With keepalive_interval=30s, opening two SSE connections on
DOMContentLoaded (satellite stream + new ground station stream) meant the
browser waited 30 seconds before receiving any response bytes from either
connection. Browsers keep their loading indicator active while connections are
pending, causing the satellite dashboard to appear stuck loading.

Fix: yield an immediate keepalive at the start of sse_stream_fanout so every
SSE endpoint flushes headers + first data to the browser instantly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 20:03:07 +00:00
James Smith 8d8ee57cec Add observation profile management UI to Ground Station panel
- OBSERVATION PROFILES section with list of configured satellites
- + ADD button opens inline form pre-filled from currently selected satellite
  and SatNOGS transmitter data (frequency, decoder type auto-detected)
- EDIT / ✕ buttons per profile row
- Form fields: frequency, decoder (FM/AFSK/GMSK/BPSK/IQ-only), min elevation,
  gain, record IQ checkbox
- UPCOMING PASSES section below profiles with friendlier empty-state message
- gsOnSatelliteChange hook updates form when satellite dropdown changes
- CSS for .gs-form-row, .gs-profile-item, .gs-form-label

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:52:25 +00:00
James Smith 4607c358ed Add ground station automation with 6-phase implementation
Phase 1 - Automated observation engine:
- utils/ground_station/scheduler.py: GroundStationScheduler fires at AOS/LOS,
  claims SDR, manages IQBus lifecycle, emits SSE events
- utils/ground_station/observation_profile.py: ObservationProfile dataclass + DB CRUD
- routes/ground_station.py: REST API for profiles, scheduler, observations, recordings,
  rotator; SSE stream; /ws/satellite_waterfall WebSocket
- DB tables: observation_profiles, ground_station_observations, ground_station_events,
  sigmf_recordings (added to utils/database.py init_db)
- app.py: ground_station_queue, WebSocket init, scheduler startup in _deferred_init
- routes/__init__.py: register ground_station_bp

Phase 2 - Doppler correction:
- utils/doppler.py: generalized DopplerTracker extracted from sstv_decoder.py;
  accepts satellite name or raw TLE tuple; thread-safe; update_tle() method
- utils/sstv/sstv_decoder.py: replace inline DopplerTracker with import from utils.doppler
- Scheduler runs 5s retune loop; calls rotator.point_to() if enabled

Phase 3 - IQ recording (SigMF):
- utils/sigmf.py: SigMFWriter writes .sigmf-data + .sigmf-meta; disk-free guard (500MB)
- utils/ground_station/consumers/sigmf_writer.py: SigMFConsumer wraps SigMFWriter

Phase 4 - Multi-decoder IQ broadcast pipeline:
- utils/ground_station/iq_bus.py: IQBus single-producer fan-out; IQConsumer Protocol
- utils/ground_station/consumers/waterfall.py: CU8→FFT→binary frames
- utils/ground_station/consumers/fm_demod.py: CU8→FM demod (numpy)→decoder subprocess
- utils/ground_station/consumers/gr_satellites.py: CU8→cf32→gr_satellites (optional)

Phase 5 - Live spectrum waterfall:
- static/js/modes/ground_station_waterfall.js: /ws/satellite_waterfall canvas renderer
- Waterfall panel in satellite dashboard sidebar, auto-shown on iq_bus_started SSE event

Phase 6 - Antenna rotator control (optional):
- utils/rotator.py: RotatorController TCP client for rotctld (Hamlib line protocol)
- Rotator panel in satellite dashboard; silently disabled if rotctld unreachable

Also fixes pre-existing test_weather_sat_predict.py breakage:
- utils/weather_sat_predict.py: rewritten with self-contained skyfield implementation
  using find_discrete (matching what committed tests expected); adds _format_utc_iso
- tests/test_weather_sat_predict.py: add _MOCK_WEATHER_SATS and @patch decorators
  for tests that assumed NOAA-18 active (decommissioned Jun 2025, now active=False)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:36:55 +00:00
James Smith ed1461626b Prefetch SatNOGS transmitter cache at startup to fix loading delay 2026-03-18 15:00:03 +00:00
James Smith ee9bd9bbb2 Fix transmitters and decoded packets panels hidden in satellite dashboard sidebar 2026-03-18 14:50:30 +00:00
James Smith 75da95b38a Speed up bulk satellite import by using executemany in a single transaction 2026-03-18 14:43:21 +00:00
James Smith 5896ebd5b7 Fix setup.sh crashing on Python < 3.13 version check due to ERR trap 2026-03-18 14:34:42 +00:00
James Smith 9e7dfbda5a Fix satellite dashboard TARGET dropdown not reflecting enabled satellites
Add auto-refresh on window focus so the dropdown updates automatically when
switching back from the sidebar, plus a manual ↺ refresh button next to the
dropdown. Also preserves the current selection across refreshes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:05:22 +00:00
James Smith dc84e933c1 Fix setup.sh hanging on Python 3.14/macOS and add satellite enhancements
- Add --no-cache-dir and --timeout 120 to all pip calls to prevent hanging
  on corrupt/stale pip HTTP cache (cachecontrol .pyc issue)
- Replace silent python -c import verification with pip show to avoid
  import-time side effects hanging the installer
- Switch optional packages to --only-binary :all: to skip source compilation
  on Python versions without pre-built wheels (prevents gevent/numpy hangs)
- Warn early when Python 3.13+ is detected that some packages may be skipped
- Add ground track caching with 30-minute TTL to satellite route
- Add live satellite position tracker background thread via SSE fanout
- Add satellite_predict, satellite_telemetry, and satnogs utilities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 11:09:00 +00:00
Smittix 3140f54419 Add apt lock wait to prevent setup.sh hanging on fresh Ubuntu VMs
On first boot, unattended-upgrades or apt-daily often holds the dpkg
lock, causing silent hangs with no user feedback. Added wait_for_apt_lock()
that polls for up to 120s with status messages, called before apt-get
update and inside apt_try_install_any.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 21:34:42 +00:00
Smittix e9fdadbbd8 Fix APRS observer location not updating after settings change
Dispatch observer-location-changed event from settings manager and
listen for it in APRS mode so manual location saves propagate to
the map and distance calculations. Also refresh ObserverLocation in
initAprsMap() to catch changes between page load and first map use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:00:35 +00:00
Smittix 8d537a61ed Change default ADS-B range rings from 200nm to 50nm
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 12:06:29 +00:00
Smittix ddf23377c3 Fix flask-compress not installing into venv
The core pip install was suppressing errors with 2>/dev/null, and the
verification check was finding packages in ~/.local/site-packages
instead of the venv. When run with sudo, ~/.local isn't visible,
causing the flask-compress warning.

- Remove --quiet and stderr suppression from core package install
- Use python -s flag in verification to ignore user site-packages
- Update health check to also verify flask-compress and flask-wtf

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:20:17 +00:00
Smittix c0138ed849 Add flask-compress and flask-wtf to setup.sh core installs
These packages were in requirements.txt but missing from the explicit
pip install commands in setup.sh, causing warnings on fresh installs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:10:57 +00:00
Smittix b5115d4aa1 Fix observer location persistence and APRS defaults 2026-03-15 17:49:46 +00:00
Smittix 6b9c4ebebd v2.26.12: fix AIS/ADS-B dashboards ignoring configured observer position
Pass DEFAULT_LATITUDE/DEFAULT_LONGITUDE from config to both standalone
dashboard templates so observer-location.js uses .env values instead of
falling back to hardcoded London coordinates on first visit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 21:11:46 +00:00
Smittix 7ed039564b v2.26.11: fix APRS map ignoring configured observer position (#193)
The APRS map initialisation only checked for a live GPS fix, falling
back to the centre of the US (39.8N, 98.6W) when none was available.
It never read the observer position configured in .env via
INTERCEPT_DEFAULT_LAT / INTERCEPT_DEFAULT_LON.

Seed aprsUserLocation from ObserverLocation.getShared() (or the
Jinja-injected defaults) on page load so the map centres on the
user's configured position and distance calculations work without GPS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:17:50 +00:00
Smittix 8adfb3a40a v2.26.10: fix APRS stop timeout and inverted SDR device status (#194)
The APRS stop endpoint terminated two processes sequentially (up to 4s
with PROCESS_TERMINATE_TIMEOUT=2s each) while the frontend fetch timed
out at 2.2s. This caused console errors and left the SDR device claimed
in the registry until termination finished, making the status panel show
the device as active after the user clicked stop.

Fix: release the SDR device from the registry immediately inside the
lock, clear process references, then terminate processes in a background
thread so the HTTP response returns instantly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:14:21 +00:00
Smittix 9a9b1e9856 v2.26.9: add rtl_biast fallback for ADS-B bias-t on Blog V4 (#195)
When dump1090 lacks native --enable-biast support, the system now falls
back to rtl_biast (RTL-SDR Blog drivers) to enable bias-t power before
starting dump1090. The Blog V4's built-in LNA requires bias-t to
receive ADS-B signals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:05:15 +00:00
Smittix 8aeb52380e v2.26.8: fix acarsdec build failure on macOS (#187)
HOST_NAME_MAX is Linux-specific and undefined on macOS, causing 3
compile errors in acarsdec.c. Now patched with #define HOST_NAME_MAX 255
before building. Also fixed deprecated -Ofast flag on all macOS archs
(was only patched for arm64).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:03:46 +00:00
Smittix 05141b9a1b v2.26.7: fix health check SDR detection on macOS (#188)
timeout (GNU coreutils) is not available on macOS, causing rtl_test to
silently fail and report no SDR device found. Now tries timeout, then
gtimeout (Homebrew coreutils), then falls back to background process
with manual kill.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:01:55 +00:00
Smittix dc0850d339 v2.26.6: fix oversized branded 'i' logo on dashboard pages (#189)
.logo span { display: inline } in dashboard CSS had specificity (0,1,1),
overriding .brand-i { display: inline-block } at (0,1,0). Inline elements
ignore width/height, so the SVG rendered at intrinsic size (~80px tall).
Added .logo .brand-i selector at (0,2,0) to retain inline-block display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:57:25 +00:00
219 changed files with 34579 additions and 10325 deletions
-42
View File
@@ -1,42 +0,0 @@
## Workflow Orchestration
### 1. Plan Node Default
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
- If something goes sideways, STOP and re-plan immediately - don't keep pushing
- Use plan mode for verification steps, not just building
- Write detailed specs upfront to reduce ambiguity
### 2. Subagent Strategy
- Use subagents liberally to keep main context window clean
- Offload research, exploration, and parallel analysis to subagents
- For complex problems, throw more compute at it via subagents
- One tack per subagent for focused execution
### 3. Self-Improvement Loop
- After ANY correction from the user: update 'tasks/lessons.md" with the pattern
- Write rules for yourself that prevent the same mistake
- Ruthlessly iterate on these lessons until mistake rate drops
- Review lessons at session start for relevant project
### 4. Verification Before Done
- Never mark a task complete without proving it works
- Diff behavior between main and your changes when relevant
- Ask yourself: "Would a staff engineer approve this?"
- Run tests, check logs, demonstrate correctness
### 5. Demand Elegance (Balanced)
- For non-trivial changes: pause and ask "is there a more elegant way?"
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
- Skip this for simple, obvious fixes - don't over-engineer
-Challenge your own work before presenting it
### 6. Autonomous Bug Fizing
- When given a bug report: just fix it. Don't ask for hand-holding
- Point at logs, errors, failing tests - then resolve them
- Zero context switching required from the user
- Go fix failing CI tests without being told how
## Task Management
1. **Plan First**: Write plan to "tasks/todo.md" with checkable items
2. **Verify Plan**: Check in before starting implementation
3. **Track Progress**: Mark items complete as you go
4. **Explain Changes**: High-level summary at each step
5. **Document Results**: Add review section to 'tasks/todo.md"
6. **Capture Lessons**: Update 'tasks/lessons.md' after corrections
## Core Principles
- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
- **Minimat Impact**: Changes should only touch what's necessary. Avoid introducing bugs.
-1
View File
@@ -42,7 +42,6 @@ tasks/
instance/
# data/ is a Python package — only exclude non-code files
data/*.json
data/*.csv
data/*.db
+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
+69
View File
@@ -0,0 +1,69 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
# Set permissions for GITHUB_TOKEN
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
# Step 1: Check out the repository code
- name: Checkout
uses: actions/checkout@v4
# Step 2: Set up QEMU for multi-arch builds
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# Step 3: Set up Docker Buildx for advanced features
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Step 4: Log in to GitHub Container Registry
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Step 5: Generate tags and labels from Git metadata
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
# Tag with branch name
type=ref,event=branch
# Tag with PR number
type=ref,event=pr
# Tag with semver from git tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
# Tag with short SHA
type=sha,prefix=
# Step 6: Build and push the Docker image
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
# Only push on main branch and tags, not PRs
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Enable build cache for faster builds
cache-from: type=gha
cache-to: type=gha,mode=max
+5
View File
@@ -67,3 +67,8 @@ data/subghz/captures/
# Local utility scripts
reset-sdr.*
.superpowers/
docs/superpowers/
# Git worktrees
.worktrees/
+7
View File
@@ -0,0 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.4
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
+178
View File
@@ -0,0 +1,178 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview
INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, satellite tracking, ISS SSTV decoding, AIS vessel tracking, weather satellite imagery (NOAA APT & Meteor LRPT), and Meshtastic mesh networking.
## Common Commands
### Docker (Primary)
```bash
# Build and run (basic profile)
docker compose --profile basic up -d
# Build and run with ADS-B history (Postgres)
docker compose --profile history up -d
# Rebuild after code changes
docker compose --profile basic up -d --build
# Multi-arch build (amd64 + arm64 for RPi)
./build-multiarch.sh
```
### Local Setup (Alternative)
```bash
# First-time setup (interactive wizard with install profiles)
./setup.sh
# Or headless full install
./setup.sh --non-interactive
# Or install specific profiles
./setup.sh --profile=core,weather
# Run with production server (gunicorn + gevent, handles concurrent SSE/WebSocket)
sudo ./start.sh
# Or for quick local dev (Flask dev server)
sudo -E venv/bin/python intercept.py
# Other setup utilities
./setup.sh --health-check # Verify installation
./setup.sh --postgres-setup # Set up ADS-B history database
./setup.sh --menu # Force interactive menu
```
### Testing
```bash
# Run all tests
pytest
# Run specific test file
pytest tests/test_bluetooth.py
# Run with coverage
pytest --cov=routes --cov=utils
# Run a specific test
pytest tests/test_bluetooth.py::test_function_name -v
```
### Linting and Formatting
```bash
# Lint with ruff
ruff check .
# Auto-fix linting issues
ruff check --fix .
# Format with black
black .
# Type checking
mypy .
```
## Architecture
### Entry Points
- `setup.sh` - Menu-driven installer with profile system (wizard, health check, PostgreSQL setup, env configurator, update, uninstall). Sources `.env` on startup via `start.sh`.
- `start.sh` - Production startup script (gunicorn + gevent auto-detection, CLI flags, HTTPS, `.env` sourcing, fallback to Flask dev server)
- `intercept.py` - Direct Flask dev server entry point (quick local development)
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure, conditional gevent monkey-patch
### Route Blueprints (routes/)
Each signal type has its own Flask blueprint:
- `pager.py` - POCSAG/FLEX decoding via rtl_fm + multimon-ng
- `sensor.py` - 433MHz IoT sensors via rtl_433
- `adsb.py` - Aircraft tracking via dump1090 (SBS protocol on port 30003)
- `acars.py` - Aircraft datalink messages via acarsdec
- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs)
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
- `satellite.py` - Pass prediction using TLE data
- `sstv.py` - ISS SSTV image decoding via slowrx
- `weather_sat.py` - NOAA APT & Meteor LRPT via SatDump
- `ais.py` - AIS vessel tracking and VHF DSC distress monitoring
- `aprs.py` - Amateur packet radio via direwolf
- `rtlamr.py` - Utility meter reading
- `meshtastic_routes.py` - Meshtastic LoRa mesh networking
### Core Utilities (utils/)
**SDR Abstraction Layer** (`utils/sdr/`):
- `SDRFactory` with factory pattern for multiple SDR types (RTL-SDR, LimeSDR, HackRF, Airspy, SDRPlay)
- Each type has a `CommandBuilder` for generating CLI commands
**Bluetooth Module** (`utils/bluetooth/`):
- Multi-backend: DBus/BlueZ primary, fallback for systems without BlueZ
- `aggregator.py` - Merges observations across time
- `tracker_signatures.py` - 47K+ known tracker fingerprints (AirTag, Tile, SmartTag)
- `heuristics.py` - Behavioral analysis for device classification
**TSCM (Counter-Surveillance)** (`utils/tscm/`):
- `baseline.py` - Snapshot "normal" RF environment
- `detector.py` - Compare current scan to baseline, flag anomalies
- `device_identity.py` - Track devices despite MAC randomization
- `correlation.py` - Cross-reference Bluetooth and WiFi observations
**WiFi Utilities** (`utils/wifi/`):
- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS)
- `channel_analyzer.py` - Frequency band analysis
**Weather Satellite** (`utils/weather_sat.py`):
- Singleton `WeatherSatDecoder` using SatDump CLI for NOAA APT and Meteor LRPT
- Subprocess management with stdout parsing, image watcher via rglob
- Pass prediction using skyfield TLE data
**SSTV Decoder** (`utils/sstv.py`):
- ISS SSTV reception via slowrx with Doppler tracking
- Singleton pattern, image gallery with timestamped filenames
### Key Patterns
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. Under gunicorn + gevent, each SSE connection is a lightweight greenlet instead of an OS thread.
**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions.
**Data Stores**: `DataStore` class with TTL-based automatic cleanup (WiFi: 10min, Bluetooth: 5min, Aircraft: 5min).
**Input Validation**: Centralized in `utils/validation.py` - always validate frequencies, gains, device indices before spawning processes.
### External Tool Integrations
| Tool | Purpose | Integration |
|------|---------|-------------|
| rtl_fm | FM demodulation | Subprocess, pipes to multimon-ng |
| multimon-ng | Pager decoding | Reads from rtl_fm stdout |
| rtl_433 | 433MHz sensors | JSON output parsing |
| dump1090 | ADS-B decoding | SBS protocol socket (port 30003) |
| acarsdec | ACARS messages | Output parsing |
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
| slowrx | SSTV decoding | Subprocess with audio pipe |
| SatDump | Weather satellites | CLI live mode, NOAA APT + Meteor LRPT |
| AIS-catcher | AIS vessel tracking | JSON output parsing |
| direwolf | APRS | TNC modem for packet radio |
### Frontend Structure
- **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()`
### Docker
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.). CMD runs `start.sh` (gunicorn + gevent)
- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B)
- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
- Data persisted via `./data:/app/data` volume mount
### Configuration
- `config.py` - Environment variable support with `INTERCEPT_` prefix (e.g., `INTERCEPT_PORT`, `INTERCEPT_WEATHER_SAT_GAIN`)
- Database: SQLite in `instance/` directory for settings, baselines, history
## Testing Notes
Tests use pytest with extensive mocking of external tools. Key fixtures in `tests/conftest.py`. Mock subprocess calls when testing decoder integration.
+64
View File
@@ -2,6 +2,70 @@
All notable changes to iNTERCEPT will be documented in this file.
## [2.27.0] - 2026-05-20
### Fixed
- **Two-window hang** — Opening the app in two browser tabs/windows caused it to become completely unresponsive. Root cause: HTTP/1.1 limits browsers to 6 connections per origin (shared across all tabs). VoiceAlerts was automatically opening 3 SSE streams per window on page load, so two windows produced 8 persistent connections and permanently blocked all regular HTTP requests. VoiceAlerts streams are now opt-in (disabled by default); users can enable them in settings.
- **Alert messages split between windows** — The `/alerts/stream` SSE endpoint read from a single queue, so two windows would each receive only half the alerts. Now uses `sse_stream_fanout` so every window gets every alert.
- **Bluetooth v2 stream split between windows** — Same single-queue issue in `/api/bluetooth/stream`. Fixed with fanout via `subscribe_fanout_queue`, preserving named SSE events (`device_update`, `scan_started`, etc.).
- **ICAO lookup cache unbounded growth** — `_looked_up_icaos` set was never evicted; capped at 50 000 entries with LRU eviction to prevent memory growth under sustained ADS-B load.
- **Concurrent ICAO clear race** — `popitem()` on the ICAO dict could raise `RuntimeError` if a clear happened concurrently; guarded with try/except.
- **Bluetooth tracker fingerprint stability** — Tracker signature scan was incorrectly resetting stability counters on unchanged payloads; now skips the scan when the BLE payload fingerprint is unchanged.
### Added
- **UI Tier system** — Three display modes selectable from the nav bar: *Lean* (minimal, no decorative elements), *Standard* (default), and *Enhanced* (full animations and ambient effects). Replaces the old animations toggle.
- **Display mode in first-run setup** — The first-run modal now includes a display mode selection step so new users can pick their preferred visual style during initial setup.
### Performance
- ADS-B SSE snapshot priming moved inside the response generator (avoids blocking before headers are sent).
- WiFi network filter combined into a single list pass instead of chained filters.
- Bluetooth tracker signature scan skips processing when the BLE payload fingerprint is unchanged.
- `DataStore` cleanup minimises lock hold time by collecting expired keys before acquiring the write lock.
---
## [2.26.11] - 2026-03-14
### Fixed
- **APRS map ignores configured observer position** — The APRS map always fell back to the centre of the US (39.8°N, 98.6°W) when no live GPS fix was available, ignoring the observer position configured in `.env` (`INTERCEPT_DEFAULT_LAT` / `INTERCEPT_DEFAULT_LON`). Now seeds the APRS user location from the shared observer location on page load, so the map centres correctly and distance calculations work. (#193)
---
## [2.26.10] - 2026-03-14
### Fixed
- **APRS stop timeout and inverted SDR device status** — The APRS stop endpoint terminated two processes sequentially (up to 4s) while the frontend timed out at 2.2s, causing console errors and the SDR status panel to show stale state (active after stop, idle during use). Now releases the SDR device immediately and terminates processes in a background thread so the response returns instantly. (#194)
---
## [2.26.9] - 2026-03-14
### Fixed
- **ADS-B bias-t support for RTL-SDR Blog V4** — When dump1090 lacks native `--enable-biast` support, the system now falls back to `rtl_biast` (from RTL-SDR Blog drivers) to enable bias-t power before starting dump1090. The Blog V4's built-in LNA requires bias-t to receive ADS-B signals. (#195)
---
## [2.26.8] - 2026-03-14
### Fixed
- **acarsdec build failure on macOS** — `HOST_NAME_MAX` is Linux-specific (`<limits.h>`) and undefined on macOS, causing 3 compile errors in `acarsdec.c`. Now patched with `#define HOST_NAME_MAX 255` before building. Also fixed deprecated `-Ofast` flag warning on all macOS architectures (was only patched for arm64). (#187)
---
## [2.26.7] - 2026-03-14
### Fixed
- **Health check SDR detection on macOS** — `timeout` (GNU coreutils) is not available on macOS, causing `rtl_test` to silently fail and report "No RTL-SDR device found" even when one is connected. Now tries `timeout`, then `gtimeout` (Homebrew coreutils), then falls back to a background process with manual kill. (#188)
---
## [2.26.6] - 2026-03-14
### Fixed
- **Oversized branded 'i' logo on dashboards** — `.logo span { display: inline }` in dashboard CSS had higher specificity (0,1,1) than `.brand-i { display: inline-block }` (0,1,0), forcing the branded "i" SVG to render as inline which ignores width/height. Added `.logo .brand-i` selector (0,2,0) to retain `inline-block` display. (#189)
---
## [2.26.5] - 2026-03-14
### Fixed
+25 -6
View File
@@ -126,6 +126,7 @@ RUN cd /tmp \
&& rm -rf /tmp/slowrx
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
# Split into compile (heavy, cached) and staging (light, safe to change) layers
RUN cd /tmp \
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
&& cd SatDump \
@@ -147,14 +148,29 @@ RUN cd /tmp \
fi; \
done; \
fi \
# Copy SatDump install artifacts to staging
&& cp -a /usr/local/bin/satdump /staging/usr/local/bin/ 2>/dev/null || true \
&& cp -a /usr/local/lib/libsatdump* /staging/usr/local/lib/ 2>/dev/null || true \
&& cp -a /usr/local/lib/satdump /staging/usr/local/lib/ 2>/dev/null || true \
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null; mkdir -p /staging/usr/local/share \
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null || true \
&& rm -rf /tmp/SatDump
# Stage SatDump artifacts (separate layer so compile cache survives staging changes)
# On arm64 cmake installs to /usr/{bin,lib,share}; on x86 to /usr/local/{bin,lib,share}
RUN mkdir -p /staging/usr/local/share /staging/usr/local/lib/satdump/plugins \
# Binary
&& (cp -a /usr/local/bin/satdump /staging/usr/local/bin/ 2>/dev/null \
|| cp -a /usr/bin/satdump /staging/usr/local/bin/) \
# Core shared library
&& (cp -a /usr/local/lib/libsatdump* /staging/usr/local/lib/ 2>/dev/null \
|| cp -a /usr/lib/libsatdump* /staging/usr/local/lib/) \
# Plugins
&& (cp -a /usr/local/lib/satdump/plugins/*.so /staging/usr/local/lib/satdump/plugins/ 2>/dev/null \
|| cp -a /usr/lib/satdump/plugins/*.so /staging/usr/local/lib/satdump/plugins/ 2>/dev/null \
|| true) \
# Pipeline definitions and resources
&& (cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null \
|| cp -a /usr/share/satdump /staging/usr/local/share/) \
# Verify
&& test -x /staging/usr/local/bin/satdump \
&& ls /staging/usr/local/share/satdump/pipelines/*.json >/dev/null 2>&1 \
&& echo "SatDump staging OK: $(ls /staging/usr/local/share/satdump/pipelines/*.json | wc -l) pipeline files"
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
RUN cd /tmp \
@@ -219,6 +235,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpng16-16 \
libtiff6 \
libjemalloc2 \
libfftw3-double3 \
libfftw3-single3 \
libvolk-bin \
libnng1 \
libzstd1 \
@@ -254,6 +272,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY --from=builder /staging/usr/bin/ /usr/bin/
COPY --from=builder /staging/usr/local/bin/ /usr/local/bin/
COPY --from=builder /staging/usr/local/lib/ /usr/local/lib/
COPY --from=builder /staging/usr/local/share/ /usr/local/share/
COPY --from=builder /staging/opt/ /opt/
# Copy radiosonde Python dependencies installed during builder stage
+1
View File
@@ -55,6 +55,7 @@ Support the developer of this open-source project
- **Spy Stations** - Number stations and diplomatic HF network database
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
- **Offline Mode** - Bundled assets for air-gapped/field deployments
- **Drone Intelligence** - Multi-vector UAV detection via ASTM F3411 Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF, and HackRF 2.4/5.8 GHz scanning with live contact map and risk scoring
---
-1
View File
File diff suppressed because one or more lines are too long
-4
View File
@@ -1,4 +0,0 @@
{
"version": "2026-02-22_17194a71",
"downloaded": "2026-02-27T10:41:04.872620Z"
}
+428 -341
View File
File diff suppressed because it is too large Load Diff
+168 -111
View File
@@ -7,37 +7,98 @@ import os
import sys
# Application version
VERSION = "2.26.5"
VERSION = "2.27.0"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"version": "2.27.0",
"date": "May 2026",
"highlights": [
"Fix: two-window hang caused by browser HTTP/1.1 connection pool exhaustion",
"Fix: SSE alert and Bluetooth streams now fan out to all windows (no more split messages)",
"Feat: UI tier system — lean, standard, enhanced display modes via nav toggle",
"Feat: first-run setup modal includes display mode selection",
"Perf: ADS-B SSE snapshot priming moved into generator; WiFi filter combined into single pass",
"Perf: Bluetooth tracker signature scan skips unchanged fingerprints",
"Fix: ICAO lookup cache capped at 50k entries with LRU eviction",
],
},
{
"version": "2.26.13",
"date": "March 2026",
"highlights": [
"Fix TSCM sweep module variable scoping and stale progress bar",
"Fix 5GHz WiFi scanning failures in deep scan and band detection",
"Fix ADS-B remote mode incorrectly stopping other SDR services",
"Fix radiosonde false 'missing' report at end of setup",
"Satellite tracker: TLE auto-refresh, polar plot fixes, pass calculation improvements",
"Fix weather satellite handoff (remove defunct METEOR-M2)",
"Add multi-arch Docker CI workflow (amd64 + arm64)",
],
},
{
"version": "2.26.12",
"date": "March 2026",
"highlights": [
"AIS and ADS-B dashboards now use configured observer position from .env",
],
},
{
"version": "2.26.11",
"date": "March 2026",
"highlights": [
"APRS map now centres on configured observer position from .env",
],
},
{
"version": "2.26.8",
"date": "March 2026",
"highlights": [
"Fix acarsdec build failure on macOS (HOST_NAME_MAX undefined)",
],
},
{
"version": "2.26.7",
"date": "March 2026",
"highlights": [
"Fix health check SDR detection on macOS (timeout command not available)",
],
},
{
"version": "2.26.6",
"date": "March 2026",
"highlights": [
"Fix oversized branded 'i' logo on Aircraft & Vessel dashboards",
],
},
{
"version": "2.26.5",
"date": "March 2026",
"highlights": [
"Fix database errors crashing the entire UI — pages now degrade gracefully",
]
],
},
{
"version": "2.26.4",
"date": "March 2026",
"highlights": [
"Fix Environment Configurator crash when .env exists but variable is missing",
]
],
},
{
"version": "2.26.3",
"date": "March 2026",
"highlights": [
"Fix SatDump AVX2 crash on older CPUs — build now targets baseline x86-64",
]
],
},
{
"version": "2.26.2",
"date": "March 2026",
"highlights": [
"Fix Docker startup crash — data/ Python package was excluded by .dockerignore",
]
],
},
{
"version": "2.26.1",
@@ -45,7 +106,7 @@ CHANGELOG = [
"highlights": [
"Fix default admin credentials — now matches README (admin:admin)",
"Admin password changes in config.py / env vars now sync to DB on restart",
]
],
},
{
"version": "2.26.0",
@@ -53,7 +114,7 @@ CHANGELOG = [
"highlights": [
"Fix SSE fanout thread crash when source queue is None during shutdown",
"Fix branded 'i' logo FOUC (flash of unstyled content) on first page load",
]
],
},
{
"version": "2.25.0",
@@ -64,7 +125,7 @@ CHANGELOG = [
"Destructive action confirmation modals replace native confirm() dialogs",
"CSS variable adoption, inline style extraction, and reduced !important usage",
"Loading button states, actionable error reporting, and mobile UX polish",
]
],
},
{
"version": "2.24.0",
@@ -74,7 +135,7 @@ CHANGELOG = [
"Mobile navigation reorganized into labeled groups for better usability",
"flask-limiter made optional for graceful degradation",
"Radiosonde setup fix — missing semver dependency",
]
],
},
{
"version": "2.23.0",
@@ -90,7 +151,7 @@ CHANGELOG = [
"GPS mode upgraded to textured 3D globe",
"Destroy lifecycle added to all mode modules to prevent resource leaks",
"Dozens of bug fixes across ADS-B, APRS, SSE, Morse, waterfall, and more",
]
],
},
{
"version": "2.22.3",
@@ -101,7 +162,7 @@ CHANGELOG = [
"Waterfall monitor audio no longer takes minutes to start — playback detection now waits for real audio data instead of just the WAV header",
"Waterfall monitor stop is now instant — audio pauses and UI updates immediately instead of waiting for backend cleanup",
"Stopping the waterfall no longer shows a stale 'WebSocket closed before ready' message",
]
],
},
{
"version": "2.22.1",
@@ -119,7 +180,7 @@ CHANGELOG = [
"WebSDR major overhaul with improved receiver management and audio streaming",
"Documentation audit: fixed license, tool names, entry points, and SSTV decoder references",
"Help modal updated with ACARS and VDL2 mode descriptions",
]
],
},
{
"version": "2.21.1",
@@ -128,7 +189,7 @@ CHANGELOG = [
"BT Locate map first-load fix with render stabilization retries during initial mode open",
"BT Locate trail restore optimization for faster startup when historical GPS points exist",
"BT Locate mode-switch map invalidation timing fix to prevent delayed/blank map render",
]
],
},
{
"version": "2.21.0",
@@ -140,7 +201,7 @@ CHANGELOG = [
"Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements",
"ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding",
"Analytics enhancements with operational insights and temporal pattern panels",
]
],
},
{
"version": "2.20.0",
@@ -151,7 +212,7 @@ CHANGELOG = [
"HF band conditions, D-RAP absorption maps, aurora forecast, and solar imagery",
"NOAA Space Weather Scales (G/S/R), flare probability, and active solar regions",
"No SDR hardware required — all data from public APIs with server-side caching",
]
],
},
{
"version": "2.19.0",
@@ -164,7 +225,7 @@ CHANGELOG = [
"Setup script overhauled for reliability and macOS compatibility",
"GPS fix for preserving satellites across DOP-only SKY messages",
"Fix gpsd deadlock causing GPS connect to hang",
]
],
},
{
"version": "2.18.0",
@@ -176,7 +237,7 @@ CHANGELOG = [
"ADS-B: stale dump1090 process cleanup via PID file tracking",
"GPS: error state indicator and UI refinements",
"Proximity radar and signal card UI improvements",
]
],
},
{
"version": "2.17.0",
@@ -186,7 +247,7 @@ CHANGELOG = [
"IRK auto-detection: extract Identity Resolving Keys from paired devices (macOS/Linux)",
"GPS mode: real-time position tracking with live map, speed, altitude, and satellite info",
"Bluetooth scanner lifecycle fix for bleak scan timeout tracking",
]
],
},
{
"version": "2.16.0",
@@ -198,7 +259,7 @@ CHANGELOG = [
"Shared waterfall UI across SDR modes",
"Listening post audio stuttering fix and SDR race condition fixes",
"Multi-arch Docker build support (amd64 + arm64)",
]
],
},
{
"version": "2.15.0",
@@ -210,7 +271,7 @@ CHANGELOG = [
"Real-time signal scope for pager, sensor, and SSTV modes",
"USB-level device probe to prevent cryptic rtl_fm crashes",
"SDR device lock-up fix from unreleased device registry on crash",
]
],
},
{
"version": "2.14.0",
@@ -221,7 +282,7 @@ CHANGELOG = [
"Listening Post signal scanner and audio pipeline improvements",
"TSCM sweep resilience, WiFi detection, and correlation fixes",
"APRS rtl_fm startup and SDR device conflict fixes",
]
],
},
{
"version": "2.13.1",
@@ -233,7 +294,7 @@ CHANGELOG = [
"WiFi connected clients panel now filters to selected AP",
"Global navigation bar across all dashboards",
"Fixed USB device contention when starting audio pipeline",
]
],
},
{
"version": "2.13.0",
@@ -243,7 +304,7 @@ CHANGELOG = [
"Help modal system with keyboard shortcuts reference",
"Global navbar and settings modal accessible from all dashboards",
"Probed SSID badges for connected clients",
]
],
},
{
"version": "2.12.1",
@@ -254,7 +315,7 @@ CHANGELOG = [
"Real-time Doppler tracking for ISS SSTV reception",
"TCP connection support for Meshtastic",
"Shared observer location with auto-start options",
]
],
},
{
"version": "2.12.0",
@@ -264,7 +325,7 @@ CHANGELOG = [
"GitHub update notifications for new releases",
"Meshtastic QR code support and telemetry display",
"New Space category with reorganized UI",
]
],
},
{
"version": "2.11.0",
@@ -274,7 +335,7 @@ CHANGELOG = [
"Ubertooth One BLE scanning support",
"Offline mode with bundled assets",
"Settings modal with tile provider configuration",
]
],
},
{
"version": "2.10.0",
@@ -284,7 +345,7 @@ CHANGELOG = [
"Spy Stations database (number stations & diplomatic HF)",
"MMSI country identification and distress alert overlays",
"SDR device conflict detection for AIS/DSC",
]
],
},
{
"version": "2.9.5",
@@ -294,7 +355,7 @@ CHANGELOG = [
"Clickable score cards and device detail expansion",
"RF scanning improvements with status feedback",
"Root privilege check and warning display",
]
],
},
{
"version": "2.9.0",
@@ -304,7 +365,7 @@ CHANGELOG = [
"TSCM baseline recording now captures device data",
"Device identity engine integration for threat detection",
"Welcome screen with mode selection",
]
],
},
{
"version": "2.8.0",
@@ -314,20 +375,20 @@ CHANGELOG = [
"WiFi/Bluetooth device correlation engine",
"Tracker detection (AirTag, Tile, SmartTag)",
"Risk scoring and threat classification",
]
],
},
]
def _get_env(key: str, default: str) -> str:
"""Get environment variable with default."""
return os.environ.get(f'INTERCEPT_{key}', default)
return os.environ.get(f"INTERCEPT_{key}", default)
def _get_env_int(key: str, default: int) -> int:
"""Get environment variable as integer with default."""
try:
return int(os.environ.get(f'INTERCEPT_{key}', str(default)))
return int(os.environ.get(f"INTERCEPT_{key}", str(default)))
except ValueError:
return default
@@ -335,134 +396,130 @@ def _get_env_int(key: str, default: int) -> int:
def _get_env_float(key: str, default: float) -> float:
"""Get environment variable as float with default."""
try:
return float(os.environ.get(f'INTERCEPT_{key}', str(default)))
return float(os.environ.get(f"INTERCEPT_{key}", str(default)))
except ValueError:
return default
def _get_env_bool(key: str, default: bool) -> bool:
"""Get environment variable as boolean with default."""
val = os.environ.get(f'INTERCEPT_{key}', '').lower()
if val in ('true', '1', 'yes', 'on'):
val = os.environ.get(f"INTERCEPT_{key}", "").lower()
if val in ("true", "1", "yes", "on"):
return True
if val in ('false', '0', 'no', 'off'):
if val in ("false", "0", "no", "off"):
return False
return default
# Logging configuration
_log_level_str = _get_env('LOG_LEVEL', 'WARNING').upper()
_log_level_str = _get_env("LOG_LEVEL", "WARNING").upper()
LOG_LEVEL = getattr(logging, _log_level_str, logging.WARNING)
LOG_FORMAT = _get_env('LOG_FORMAT', '%(asctime)s - %(levelname)s - %(message)s')
LOG_FORMAT = _get_env("LOG_FORMAT", "%(asctime)s - %(levelname)s - %(message)s")
# Server settings
HOST = _get_env('HOST', '0.0.0.0')
PORT = _get_env_int('PORT', 5050)
DEBUG = _get_env_bool('DEBUG', False)
THREADED = _get_env_bool('THREADED', True)
HOST = _get_env("HOST", "0.0.0.0")
PORT = _get_env_int("PORT", 5050)
DEBUG = _get_env_bool("DEBUG", False)
THREADED = _get_env_bool("THREADED", True)
# HTTPS / SSL settings
HTTPS = _get_env_bool('HTTPS', False)
SSL_CERT = _get_env('SSL_CERT', '')
SSL_KEY = _get_env('SSL_KEY', '')
HTTPS = _get_env_bool("HTTPS", False)
SSL_CERT = _get_env("SSL_CERT", "")
SSL_KEY = _get_env("SSL_KEY", "")
# Default RTL-SDR settings
DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40')
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
DEFAULT_GAIN = _get_env("DEFAULT_GAIN", "40")
DEFAULT_DEVICE = _get_env("DEFAULT_DEVICE", "0")
# Pager defaults
DEFAULT_PAGER_FREQ = _get_env('PAGER_FREQ', '929.6125M')
DEFAULT_PAGER_FREQ = _get_env("PAGER_FREQ", "929.6125M")
# Timeouts
PROCESS_TIMEOUT = _get_env_int('PROCESS_TIMEOUT', 5)
SOCKET_TIMEOUT = _get_env_int('SOCKET_TIMEOUT', 5)
SSE_TIMEOUT = _get_env_int('SSE_TIMEOUT', 1)
PROCESS_TIMEOUT = _get_env_int("PROCESS_TIMEOUT", 5)
SOCKET_TIMEOUT = _get_env_int("SOCKET_TIMEOUT", 5)
SSE_TIMEOUT = _get_env_int("SSE_TIMEOUT", 1)
# WiFi settings
WIFI_UPDATE_INTERVAL = _get_env_float('WIFI_UPDATE_INTERVAL', 2.0)
AIRODUMP_HEADER_LINES = _get_env_int('AIRODUMP_HEADER_LINES', 2)
WIFI_UPDATE_INTERVAL = _get_env_float("WIFI_UPDATE_INTERVAL", 2.0)
AIRODUMP_HEADER_LINES = _get_env_int("AIRODUMP_HEADER_LINES", 2)
# Bluetooth settings
BT_SCAN_TIMEOUT = _get_env_int('BT_SCAN_TIMEOUT', 10)
BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
BT_SCAN_TIMEOUT = _get_env_int("BT_SCAN_TIMEOUT", 10)
BT_UPDATE_INTERVAL = _get_env_float("BT_UPDATE_INTERVAL", 2.0)
# ADS-B settings
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
ADSB_AUTO_START = _get_env_bool('ADSB_AUTO_START', False)
ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False)
ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
ADSB_DB_NAME = _get_env('ADSB_DB_NAME', 'intercept_adsb')
ADSB_DB_USER = _get_env('ADSB_DB_USER', 'intercept')
ADSB_DB_PASSWORD = _get_env('ADSB_DB_PASSWORD', 'intercept')
ADSB_HISTORY_BATCH_SIZE = _get_env_int('ADSB_HISTORY_BATCH_SIZE', 500)
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0)
ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
ADSB_SBS_PORT = _get_env_int("ADSB_SBS_PORT", 30003)
ADSB_UPDATE_INTERVAL = _get_env_float("ADSB_UPDATE_INTERVAL", 1.0)
ADSB_AUTO_START = _get_env_bool("ADSB_AUTO_START", False)
ADSB_HISTORY_ENABLED = _get_env_bool("ADSB_HISTORY_ENABLED", False)
ADSB_DB_HOST = _get_env("ADSB_DB_HOST", "localhost")
ADSB_DB_PORT = _get_env_int("ADSB_DB_PORT", 5432)
ADSB_DB_NAME = _get_env("ADSB_DB_NAME", "intercept_adsb")
ADSB_DB_USER = _get_env("ADSB_DB_USER", "intercept")
ADSB_DB_PASSWORD = _get_env("ADSB_DB_PASSWORD", "intercept")
ADSB_HISTORY_BATCH_SIZE = _get_env_int("ADSB_HISTORY_BATCH_SIZE", 500)
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float("ADSB_HISTORY_FLUSH_INTERVAL", 1.0)
ADSB_HISTORY_QUEUE_SIZE = _get_env_int("ADSB_HISTORY_QUEUE_SIZE", 50000)
# Observer location settings
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool('SHARED_OBSERVER_LOCATION', True)
DEFAULT_LATITUDE = _get_env_float('DEFAULT_LAT', 0.0)
DEFAULT_LONGITUDE = _get_env_float('DEFAULT_LON', 0.0)
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool("SHARED_OBSERVER_LOCATION", True)
DEFAULT_LATITUDE = _get_env_float("DEFAULT_LAT", 0.0)
DEFAULT_LONGITUDE = _get_env_float("DEFAULT_LON", 0.0)
# Satellite settings
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
SATELLITE_UPDATE_INTERVAL = _get_env_int("SATELLITE_UPDATE_INTERVAL", 30)
SATELLITE_TRAJECTORY_POINTS = _get_env_int("SATELLITE_TRAJECTORY_POINTS", 30)
SATELLITE_ORBIT_MINUTES = _get_env_int("SATELLITE_ORBIT_MINUTES", 45)
# Weather satellite settings
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 2400000)
WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0)
WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30)
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30)
WEATHER_SAT_DEFAULT_GAIN = _get_env_float("WEATHER_SAT_GAIN", 30.0)
WEATHER_SAT_SAMPLE_RATE = _get_env_int("WEATHER_SAT_SAMPLE_RATE", 2400000)
WEATHER_SAT_MIN_ELEVATION = _get_env_float("WEATHER_SAT_MIN_ELEVATION", 15.0)
WEATHER_SAT_PREDICTION_HOURS = _get_env_int("WEATHER_SAT_PREDICTION_HOURS", 24)
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int("WEATHER_SAT_SCHEDULE_REFRESH_MINUTES", 30)
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int("WEATHER_SAT_CAPTURE_BUFFER_SECONDS", 30)
# WeFax (Weather Fax) settings
WEFAX_DEFAULT_GAIN = _get_env_float('WEFAX_GAIN', 40.0)
WEFAX_SAMPLE_RATE = _get_env_int('WEFAX_SAMPLE_RATE', 22050)
WEFAX_DEFAULT_IOC = _get_env_int('WEFAX_IOC', 576)
WEFAX_DEFAULT_LPM = _get_env_int('WEFAX_LPM', 120)
WEFAX_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEFAX_SCHEDULE_REFRESH_MINUTES', 30)
WEFAX_CAPTURE_BUFFER_SECONDS = _get_env_int('WEFAX_CAPTURE_BUFFER_SECONDS', 30)
WEFAX_DEFAULT_GAIN = _get_env_float("WEFAX_GAIN", 40.0)
WEFAX_SAMPLE_RATE = _get_env_int("WEFAX_SAMPLE_RATE", 22050)
WEFAX_DEFAULT_IOC = _get_env_int("WEFAX_IOC", 576)
WEFAX_DEFAULT_LPM = _get_env_int("WEFAX_LPM", 120)
WEFAX_SCHEDULE_REFRESH_MINUTES = _get_env_int("WEFAX_SCHEDULE_REFRESH_MINUTES", 30)
WEFAX_CAPTURE_BUFFER_SECONDS = _get_env_int("WEFAX_CAPTURE_BUFFER_SECONDS", 30)
# SubGHz transceiver settings (HackRF)
SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92)
SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int('SUBGHZ_SAMPLE_RATE', 2000000)
SUBGHZ_DEFAULT_LNA_GAIN = _get_env_int('SUBGHZ_LNA_GAIN', 32)
SUBGHZ_DEFAULT_VGA_GAIN = _get_env_int('SUBGHZ_VGA_GAIN', 20)
SUBGHZ_DEFAULT_TX_GAIN = _get_env_int('SUBGHZ_TX_GAIN', 20)
SUBGHZ_MAX_TX_DURATION = _get_env_int('SUBGHZ_MAX_TX_DURATION', 10)
SUBGHZ_SWEEP_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0)
SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.0)
SUBGHZ_DEFAULT_FREQUENCY = _get_env_float("SUBGHZ_FREQUENCY", 433.92)
SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int("SUBGHZ_SAMPLE_RATE", 2000000)
SUBGHZ_DEFAULT_LNA_GAIN = _get_env_int("SUBGHZ_LNA_GAIN", 32)
SUBGHZ_DEFAULT_VGA_GAIN = _get_env_int("SUBGHZ_VGA_GAIN", 20)
SUBGHZ_DEFAULT_TX_GAIN = _get_env_int("SUBGHZ_TX_GAIN", 20)
SUBGHZ_MAX_TX_DURATION = _get_env_int("SUBGHZ_MAX_TX_DURATION", 10)
SUBGHZ_SWEEP_START_MHZ = _get_env_float("SUBGHZ_SWEEP_START", 300.0)
SUBGHZ_SWEEP_END_MHZ = _get_env_float("SUBGHZ_SWEEP_END", 928.0)
# Radiosonde settings
RADIOSONDE_FREQ_MIN = _get_env_float('RADIOSONDE_FREQ_MIN', 400.0)
RADIOSONDE_FREQ_MAX = _get_env_float('RADIOSONDE_FREQ_MAX', 406.0)
RADIOSONDE_DEFAULT_GAIN = _get_env_float('RADIOSONDE_GAIN', 40.0)
RADIOSONDE_UDP_PORT = _get_env_int('RADIOSONDE_UDP_PORT', 55673)
RADIOSONDE_FREQ_MIN = _get_env_float("RADIOSONDE_FREQ_MIN", 400.0)
RADIOSONDE_FREQ_MAX = _get_env_float("RADIOSONDE_FREQ_MAX", 406.0)
RADIOSONDE_DEFAULT_GAIN = _get_env_float("RADIOSONDE_GAIN", 40.0)
RADIOSONDE_UDP_PORT = _get_env_int("RADIOSONDE_UDP_PORT", 55673)
# Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
GITHUB_REPO = _get_env("GITHUB_REPO", "smittix/intercept")
UPDATE_CHECK_ENABLED = _get_env_bool("UPDATE_CHECK_ENABLED", True)
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int("UPDATE_CHECK_INTERVAL_HOURS", 6)
# Alerting
ALERT_WEBHOOK_URL = _get_env('ALERT_WEBHOOK_URL', '')
ALERT_WEBHOOK_SECRET = _get_env('ALERT_WEBHOOK_SECRET', '')
ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
ALERT_WEBHOOK_URL = _get_env("ALERT_WEBHOOK_URL", "")
ALERT_WEBHOOK_SECRET = _get_env("ALERT_WEBHOOK_SECRET", "")
ALERT_WEBHOOK_TIMEOUT = _get_env_int("ALERT_WEBHOOK_TIMEOUT", 5)
# Admin credentials
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
ADMIN_USERNAME = _get_env("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = _get_env("ADMIN_PASSWORD", "admin")
def configure_logging() -> None:
"""Configure application logging."""
logging.basicConfig(
level=LOG_LEVEL,
format=LOG_FORMAT,
stream=sys.stderr
)
logging.basicConfig(level=LOG_LEVEL, format=LOG_FORMAT, stream=sys.stderr)
# Suppress Flask development server warning
logging.getLogger('werkzeug').setLevel(LOG_LEVEL)
logging.getLogger("werkzeug").setLevel(LOG_LEVEL)
+375 -104
View File
@@ -4,18 +4,18 @@ import json
import logging
import os
logger = logging.getLogger('intercept.oui')
logger = logging.getLogger("intercept.oui")
def load_oui_database() -> dict[str, str] | None:
"""Load OUI database from external JSON file, with fallback to built-in."""
oui_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'oui_database.json')
oui_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "oui_database.json")
try:
if os.path.exists(oui_file):
with open(oui_file) as f:
data = json.load(f)
# Remove comment fields
return {k: v for k, v in data.items() if not k.startswith('_')}
return {k: v for k, v in data.items() if not k.startswith("_")}
except Exception as e:
logger.warning(f"Error loading oui_database.json: {e}, using built-in database")
return None # Will fall back to built-in
@@ -24,143 +24,414 @@ def load_oui_database() -> dict[str, str] | None:
def get_manufacturer(mac: str) -> str:
"""Look up manufacturer from MAC address OUI."""
prefix = mac[:8].upper()
return OUI_DATABASE.get(prefix, 'Unknown')
return OUI_DATABASE.get(prefix, "Unknown")
# OUI Database for manufacturer lookup (expanded)
OUI_DATABASE = {
# Apple (extensive list)
'00:25:DB': 'Apple', '04:52:F3': 'Apple', '0C:3E:9F': 'Apple', '10:94:BB': 'Apple',
'14:99:E2': 'Apple', '20:78:F0': 'Apple', '28:6A:BA': 'Apple', '3C:22:FB': 'Apple',
'40:98:AD': 'Apple', '48:D7:05': 'Apple', '4C:57:CA': 'Apple', '54:4E:90': 'Apple',
'5C:97:F3': 'Apple', '60:F8:1D': 'Apple', '68:DB:CA': 'Apple', '70:56:81': 'Apple',
'78:7B:8A': 'Apple', '7C:D1:C3': 'Apple', '84:FC:FE': 'Apple', '8C:2D:AA': 'Apple',
'90:B0:ED': 'Apple', '98:01:A7': 'Apple', '98:D6:BB': 'Apple', 'A4:D1:D2': 'Apple',
'AC:BC:32': 'Apple', 'B0:34:95': 'Apple', 'B8:C1:11': 'Apple', 'C8:69:CD': 'Apple',
'D0:03:4B': 'Apple', 'DC:A9:04': 'Apple', 'E0:C7:67': 'Apple', 'F0:18:98': 'Apple',
'F4:5C:89': 'Apple', '78:4F:43': 'Apple', '00:CD:FE': 'Apple', '04:4B:ED': 'Apple',
'04:D3:CF': 'Apple', '08:66:98': 'Apple', '0C:74:C2': 'Apple', '10:DD:B1': 'Apple',
'14:10:9F': 'Apple', '18:EE:69': 'Apple', '1C:36:BB': 'Apple', '24:A0:74': 'Apple',
'28:37:37': 'Apple', '2C:BE:08': 'Apple', '34:08:BC': 'Apple', '38:C9:86': 'Apple',
'3C:06:30': 'Apple', '44:D8:84': 'Apple', '48:A9:1C': 'Apple', '4C:32:75': 'Apple',
'50:32:37': 'Apple', '54:26:96': 'Apple', '58:B0:35': 'Apple', '5C:F7:E6': 'Apple',
'64:A3:CB': 'Apple', '68:FE:F7': 'Apple', '6C:4D:73': 'Apple', '70:DE:E2': 'Apple',
'74:E2:F5': 'Apple', '78:67:D7': 'Apple', '7C:04:D0': 'Apple', '80:E6:50': 'Apple',
'84:78:8B': 'Apple', '88:66:A5': 'Apple', '8C:85:90': 'Apple', '94:E9:6A': 'Apple',
'9C:F4:8E': 'Apple', 'A0:99:9B': 'Apple', 'A4:83:E7': 'Apple', 'A8:5C:2C': 'Apple',
'AC:1F:74': 'Apple', 'B0:19:C6': 'Apple', 'B4:F1:DA': 'Apple', 'BC:52:B7': 'Apple',
'C0:A5:3E': 'Apple', 'C4:B3:01': 'Apple', 'CC:20:E8': 'Apple', 'D0:C5:F3': 'Apple',
'D4:61:9D': 'Apple', 'D8:1C:79': 'Apple', 'E0:5F:45': 'Apple', 'E4:C6:3D': 'Apple',
'F0:B4:79': 'Apple', 'F4:0F:24': 'Apple', 'F8:4D:89': 'Apple', 'FC:D8:48': 'Apple',
"00:25:DB": "Apple",
"04:52:F3": "Apple",
"0C:3E:9F": "Apple",
"10:94:BB": "Apple",
"14:99:E2": "Apple",
"20:78:F0": "Apple",
"28:6A:BA": "Apple",
"3C:22:FB": "Apple",
"40:98:AD": "Apple",
"48:D7:05": "Apple",
"4C:57:CA": "Apple",
"54:4E:90": "Apple",
"5C:97:F3": "Apple",
"60:F8:1D": "Apple",
"68:DB:CA": "Apple",
"70:56:81": "Apple",
"78:7B:8A": "Apple",
"7C:D1:C3": "Apple",
"84:FC:FE": "Apple",
"8C:2D:AA": "Apple",
"90:B0:ED": "Apple",
"98:01:A7": "Apple",
"98:D6:BB": "Apple",
"A4:D1:D2": "Apple",
"AC:BC:32": "Apple",
"B0:34:95": "Apple",
"B8:C1:11": "Apple",
"C8:69:CD": "Apple",
"D0:03:4B": "Apple",
"DC:A9:04": "Apple",
"E0:C7:67": "Apple",
"F0:18:98": "Apple",
"F4:5C:89": "Apple",
"78:4F:43": "Apple",
"00:CD:FE": "Apple",
"04:4B:ED": "Apple",
"04:D3:CF": "Apple",
"08:66:98": "Apple",
"0C:74:C2": "Apple",
"10:DD:B1": "Apple",
"14:10:9F": "Apple",
"18:EE:69": "Apple",
"1C:36:BB": "Apple",
"24:A0:74": "Apple",
"28:37:37": "Apple",
"2C:BE:08": "Apple",
"34:08:BC": "Apple",
"38:C9:86": "Apple",
"3C:06:30": "Apple",
"44:D8:84": "Apple",
"48:A9:1C": "Apple",
"4C:32:75": "Apple",
"50:32:37": "Apple",
"54:26:96": "Apple",
"58:B0:35": "Apple",
"5C:F7:E6": "Apple",
"64:A3:CB": "Apple",
"68:FE:F7": "Apple",
"6C:4D:73": "Apple",
"70:DE:E2": "Apple",
"74:E2:F5": "Apple",
"78:67:D7": "Apple",
"7C:04:D0": "Apple",
"80:E6:50": "Apple",
"84:78:8B": "Apple",
"88:66:A5": "Apple",
"8C:85:90": "Apple",
"94:E9:6A": "Apple",
"9C:F4:8E": "Apple",
"A0:99:9B": "Apple",
"A4:83:E7": "Apple",
"A8:5C:2C": "Apple",
"AC:1F:74": "Apple",
"B0:19:C6": "Apple",
"B4:F1:DA": "Apple",
"BC:52:B7": "Apple",
"C0:A5:3E": "Apple",
"C4:B3:01": "Apple",
"CC:20:E8": "Apple",
"D0:C5:F3": "Apple",
"D4:61:9D": "Apple",
"D8:1C:79": "Apple",
"E0:5F:45": "Apple",
"E4:C6:3D": "Apple",
"F0:B4:79": "Apple",
"F4:0F:24": "Apple",
"F8:4D:89": "Apple",
"FC:D8:48": "Apple",
# Samsung
'00:1B:66': 'Samsung', '00:21:19': 'Samsung', '00:26:37': 'Samsung', '5C:0A:5B': 'Samsung',
'8C:71:F8': 'Samsung', 'C4:73:1E': 'Samsung', '38:2C:4A': 'Samsung', '00:1E:4C': 'Samsung',
'00:12:47': 'Samsung', '00:15:99': 'Samsung', '00:17:D5': 'Samsung', '00:1D:F6': 'Samsung',
'00:21:D1': 'Samsung', '00:24:54': 'Samsung', '00:26:5D': 'Samsung', '08:D4:2B': 'Samsung',
'10:D5:42': 'Samsung', '14:49:E0': 'Samsung', '18:3A:2D': 'Samsung', '1C:66:AA': 'Samsung',
'24:4B:81': 'Samsung', '28:98:7B': 'Samsung', '2C:AE:2B': 'Samsung', '30:96:FB': 'Samsung',
'34:C3:AC': 'Samsung', '38:01:95': 'Samsung', '3C:5A:37': 'Samsung', '40:0E:85': 'Samsung',
'44:4E:1A': 'Samsung', '4C:BC:A5': 'Samsung', '50:01:BB': 'Samsung', '50:A4:D0': 'Samsung',
'54:88:0E': 'Samsung', '58:C3:8B': 'Samsung', '5C:2E:59': 'Samsung', '60:D0:A9': 'Samsung',
'64:B3:10': 'Samsung', '68:48:98': 'Samsung', '6C:2F:2C': 'Samsung', '70:F9:27': 'Samsung',
'74:45:8A': 'Samsung', '78:47:1D': 'Samsung', '7C:0B:C6': 'Samsung', '84:11:9E': 'Samsung',
'88:32:9B': 'Samsung', '8C:77:12': 'Samsung', '90:18:7C': 'Samsung', '94:35:0A': 'Samsung',
'98:52:B1': 'Samsung', '9C:02:98': 'Samsung', 'A0:0B:BA': 'Samsung', 'A4:7B:85': 'Samsung',
'A8:06:00': 'Samsung', 'AC:5F:3E': 'Samsung', 'B0:72:BF': 'Samsung', 'B4:79:A7': 'Samsung',
'BC:44:86': 'Samsung', 'C0:97:27': 'Samsung', 'C4:42:02': 'Samsung', 'CC:07:AB': 'Samsung',
'D0:22:BE': 'Samsung', 'D4:87:D8': 'Samsung', 'D8:90:E8': 'Samsung', 'E4:7C:F9': 'Samsung',
'E8:50:8B': 'Samsung', 'F0:25:B7': 'Samsung', 'F4:7B:5E': 'Samsung', 'FC:A1:3E': 'Samsung',
"00:1B:66": "Samsung",
"00:21:19": "Samsung",
"00:26:37": "Samsung",
"5C:0A:5B": "Samsung",
"8C:71:F8": "Samsung",
"C4:73:1E": "Samsung",
"38:2C:4A": "Samsung",
"00:1E:4C": "Samsung",
"00:12:47": "Samsung",
"00:15:99": "Samsung",
"00:17:D5": "Samsung",
"00:1D:F6": "Samsung",
"00:21:D1": "Samsung",
"00:24:54": "Samsung",
"00:26:5D": "Samsung",
"08:D4:2B": "Samsung",
"10:D5:42": "Samsung",
"14:49:E0": "Samsung",
"18:3A:2D": "Samsung",
"1C:66:AA": "Samsung",
"24:4B:81": "Samsung",
"28:98:7B": "Samsung",
"2C:AE:2B": "Samsung",
"30:96:FB": "Samsung",
"34:C3:AC": "Samsung",
"38:01:95": "Samsung",
"3C:5A:37": "Samsung",
"40:0E:85": "Samsung",
"44:4E:1A": "Samsung",
"4C:BC:A5": "Samsung",
"50:01:BB": "Samsung",
"50:A4:D0": "Samsung",
"54:88:0E": "Samsung",
"58:C3:8B": "Samsung",
"5C:2E:59": "Samsung",
"60:D0:A9": "Samsung",
"64:B3:10": "Samsung",
"68:48:98": "Samsung",
"6C:2F:2C": "Samsung",
"70:F9:27": "Samsung",
"74:45:8A": "Samsung",
"78:47:1D": "Samsung",
"7C:0B:C6": "Samsung",
"84:11:9E": "Samsung",
"88:32:9B": "Samsung",
"8C:77:12": "Samsung",
"90:18:7C": "Samsung",
"94:35:0A": "Samsung",
"98:52:B1": "Samsung",
"9C:02:98": "Samsung",
"A0:0B:BA": "Samsung",
"A4:7B:85": "Samsung",
"A8:06:00": "Samsung",
"AC:5F:3E": "Samsung",
"B0:72:BF": "Samsung",
"B4:79:A7": "Samsung",
"BC:44:86": "Samsung",
"C0:97:27": "Samsung",
"C4:42:02": "Samsung",
"CC:07:AB": "Samsung",
"D0:22:BE": "Samsung",
"D4:87:D8": "Samsung",
"D8:90:E8": "Samsung",
"E4:7C:F9": "Samsung",
"E8:50:8B": "Samsung",
"F0:25:B7": "Samsung",
"F4:7B:5E": "Samsung",
"FC:A1:3E": "Samsung",
# Google
'54:60:09': 'Google', '00:1A:11': 'Google', 'F4:F5:D8': 'Google', '94:EB:2C': 'Google',
'64:B5:C6': 'Google', '3C:5A:B4': 'Google', 'F8:8F:CA': 'Google', '20:DF:B9': 'Google',
'54:27:1E': 'Google', '58:CB:52': 'Google', 'A4:77:33': 'Google', 'F4:0E:22': 'Google',
"54:60:09": "Google",
"00:1A:11": "Google",
"F4:F5:D8": "Google",
"94:EB:2C": "Google",
"64:B5:C6": "Google",
"3C:5A:B4": "Google",
"F8:8F:CA": "Google",
"20:DF:B9": "Google",
"54:27:1E": "Google",
"58:CB:52": "Google",
"A4:77:33": "Google",
"F4:0E:22": "Google",
# Sony
'00:13:A9': 'Sony', '00:1D:28': 'Sony', '00:24:BE': 'Sony', '04:5D:4B': 'Sony',
'08:A9:5A': 'Sony', '10:4F:A8': 'Sony', '24:21:AB': 'Sony', '30:52:CB': 'Sony',
'40:B8:37': 'Sony', '58:48:22': 'Sony', '70:9E:29': 'Sony', '84:00:D2': 'Sony',
'AC:9B:0A': 'Sony', 'B4:52:7D': 'Sony', 'BC:60:A7': 'Sony', 'FC:0F:E6': 'Sony',
"00:13:A9": "Sony",
"00:1D:28": "Sony",
"00:24:BE": "Sony",
"04:5D:4B": "Sony",
"08:A9:5A": "Sony",
"10:4F:A8": "Sony",
"24:21:AB": "Sony",
"30:52:CB": "Sony",
"40:B8:37": "Sony",
"58:48:22": "Sony",
"70:9E:29": "Sony",
"84:00:D2": "Sony",
"AC:9B:0A": "Sony",
"B4:52:7D": "Sony",
"BC:60:A7": "Sony",
"FC:0F:E6": "Sony",
# Bose
'00:0C:8A': 'Bose', '04:52:C7': 'Bose', '08:DF:1F': 'Bose', '2C:41:A1': 'Bose',
'4C:87:5D': 'Bose', '60:AB:D2': 'Bose', '88:C9:E8': 'Bose', 'D8:9C:67': 'Bose',
"00:0C:8A": "Bose",
"04:52:C7": "Bose",
"08:DF:1F": "Bose",
"2C:41:A1": "Bose",
"4C:87:5D": "Bose",
"60:AB:D2": "Bose",
"88:C9:E8": "Bose",
"D8:9C:67": "Bose",
# JBL/Harman
'00:1D:DF': 'JBL', '08:AE:D6': 'JBL', '20:3C:AE': 'JBL', '44:5E:F3': 'JBL',
'50:C9:71': 'JBL', '74:5E:1C': 'JBL', '88:C6:26': 'JBL', 'AC:12:2F': 'JBL',
"00:1D:DF": "JBL",
"08:AE:D6": "JBL",
"20:3C:AE": "JBL",
"44:5E:F3": "JBL",
"50:C9:71": "JBL",
"74:5E:1C": "JBL",
"88:C6:26": "JBL",
"AC:12:2F": "JBL",
# Beats (Apple subsidiary)
'00:61:71': 'Beats', '48:D6:D5': 'Beats', '9C:64:8B': 'Beats', 'A4:E9:75': 'Beats',
"00:61:71": "Beats",
"48:D6:D5": "Beats",
"9C:64:8B": "Beats",
"A4:E9:75": "Beats",
# Jabra/GN Audio
'00:13:17': 'Jabra', '1C:48:F9': 'Jabra', '50:C2:ED': 'Jabra', '70:BF:92': 'Jabra',
'74:5C:4B': 'Jabra', '94:16:25': 'Jabra', 'D0:81:7A': 'Jabra', 'E8:EE:CC': 'Jabra',
"00:13:17": "Jabra",
"1C:48:F9": "Jabra",
"50:C2:ED": "Jabra",
"70:BF:92": "Jabra",
"74:5C:4B": "Jabra",
"94:16:25": "Jabra",
"D0:81:7A": "Jabra",
"E8:EE:CC": "Jabra",
# Sennheiser
'00:1B:66': 'Sennheiser', '00:22:27': 'Sennheiser', 'B8:AD:3E': 'Sennheiser',
"00:1B:66": "Sennheiser",
"00:22:27": "Sennheiser",
"B8:AD:3E": "Sennheiser",
# Xiaomi
'04:CF:8C': 'Xiaomi', '0C:1D:AF': 'Xiaomi', '10:2A:B3': 'Xiaomi', '18:59:36': 'Xiaomi',
'20:47:DA': 'Xiaomi', '28:6C:07': 'Xiaomi', '34:CE:00': 'Xiaomi', '38:A4:ED': 'Xiaomi',
'44:23:7C': 'Xiaomi', '50:64:2B': 'Xiaomi', '58:44:98': 'Xiaomi', '64:09:80': 'Xiaomi',
'74:23:44': 'Xiaomi', '78:02:F8': 'Xiaomi', '7C:1C:4E': 'Xiaomi', '84:F3:EB': 'Xiaomi',
'8C:BE:BE': 'Xiaomi', '98:FA:E3': 'Xiaomi', 'A4:77:58': 'Xiaomi', 'AC:C1:EE': 'Xiaomi',
'B0:E2:35': 'Xiaomi', 'C4:0B:CB': 'Xiaomi', 'C8:47:8C': 'Xiaomi', 'D4:97:0B': 'Xiaomi',
'E4:46:DA': 'Xiaomi', 'F0:B4:29': 'Xiaomi', 'FC:64:BA': 'Xiaomi',
"04:CF:8C": "Xiaomi",
"0C:1D:AF": "Xiaomi",
"10:2A:B3": "Xiaomi",
"18:59:36": "Xiaomi",
"20:47:DA": "Xiaomi",
"28:6C:07": "Xiaomi",
"34:CE:00": "Xiaomi",
"38:A4:ED": "Xiaomi",
"44:23:7C": "Xiaomi",
"50:64:2B": "Xiaomi",
"58:44:98": "Xiaomi",
"64:09:80": "Xiaomi",
"74:23:44": "Xiaomi",
"78:02:F8": "Xiaomi",
"7C:1C:4E": "Xiaomi",
"84:F3:EB": "Xiaomi",
"8C:BE:BE": "Xiaomi",
"98:FA:E3": "Xiaomi",
"A4:77:58": "Xiaomi",
"AC:C1:EE": "Xiaomi",
"B0:E2:35": "Xiaomi",
"C4:0B:CB": "Xiaomi",
"C8:47:8C": "Xiaomi",
"D4:97:0B": "Xiaomi",
"E4:46:DA": "Xiaomi",
"F0:B4:29": "Xiaomi",
"FC:64:BA": "Xiaomi",
# Huawei
'00:18:82': 'Huawei', '00:1E:10': 'Huawei', '00:25:68': 'Huawei', '04:B0:E7': 'Huawei',
'08:63:61': 'Huawei', '10:1B:54': 'Huawei', '18:DE:D7': 'Huawei', '20:A6:80': 'Huawei',
'28:31:52': 'Huawei', '34:12:98': 'Huawei', '3C:47:11': 'Huawei', '48:00:31': 'Huawei',
'4C:50:77': 'Huawei', '5C:7D:5E': 'Huawei', '60:DE:44': 'Huawei', '70:72:3C': 'Huawei',
'78:F5:57': 'Huawei', '80:B6:86': 'Huawei', '88:53:D4': 'Huawei', '94:04:9C': 'Huawei',
'A4:99:47': 'Huawei', 'B4:15:13': 'Huawei', 'BC:76:70': 'Huawei', 'C8:D1:5E': 'Huawei',
'DC:D2:FC': 'Huawei', 'E4:68:A3': 'Huawei', 'F4:63:1F': 'Huawei',
"00:18:82": "Huawei",
"00:1E:10": "Huawei",
"00:25:68": "Huawei",
"04:B0:E7": "Huawei",
"08:63:61": "Huawei",
"10:1B:54": "Huawei",
"18:DE:D7": "Huawei",
"20:A6:80": "Huawei",
"28:31:52": "Huawei",
"34:12:98": "Huawei",
"3C:47:11": "Huawei",
"48:00:31": "Huawei",
"4C:50:77": "Huawei",
"5C:7D:5E": "Huawei",
"60:DE:44": "Huawei",
"70:72:3C": "Huawei",
"78:F5:57": "Huawei",
"80:B6:86": "Huawei",
"88:53:D4": "Huawei",
"94:04:9C": "Huawei",
"A4:99:47": "Huawei",
"B4:15:13": "Huawei",
"BC:76:70": "Huawei",
"C8:D1:5E": "Huawei",
"DC:D2:FC": "Huawei",
"E4:68:A3": "Huawei",
"F4:63:1F": "Huawei",
# OnePlus/BBK
'64:A2:F9': 'OnePlus', 'C0:EE:FB': 'OnePlus', '94:65:2D': 'OnePlus',
"64:A2:F9": "OnePlus",
"C0:EE:FB": "OnePlus",
"94:65:2D": "OnePlus",
# Fitbit
'2C:09:4D': 'Fitbit', 'C4:D9:87': 'Fitbit', 'E4:88:6D': 'Fitbit',
"2C:09:4D": "Fitbit",
"C4:D9:87": "Fitbit",
"E4:88:6D": "Fitbit",
# Garmin
'00:1C:D1': 'Garmin', 'C4:AC:59': 'Garmin', 'E8:0F:C8': 'Garmin',
"00:1C:D1": "Garmin",
"C4:AC:59": "Garmin",
"E8:0F:C8": "Garmin",
# Microsoft
'00:50:F2': 'Microsoft', '28:18:78': 'Microsoft', '60:45:BD': 'Microsoft',
'7C:1E:52': 'Microsoft', '98:5F:D3': 'Microsoft', 'B4:0E:DE': 'Microsoft',
"00:50:F2": "Microsoft",
"28:18:78": "Microsoft",
"60:45:BD": "Microsoft",
"7C:1E:52": "Microsoft",
"98:5F:D3": "Microsoft",
"B4:0E:DE": "Microsoft",
# Intel
'00:1B:21': 'Intel', '00:1C:C0': 'Intel', '00:1E:64': 'Intel', '00:21:5C': 'Intel',
'08:D4:0C': 'Intel', '18:1D:EA': 'Intel', '34:02:86': 'Intel', '40:74:E0': 'Intel',
'48:51:B7': 'Intel', '58:A0:23': 'Intel', '64:D4:DA': 'Intel', '80:19:34': 'Intel',
'8C:8D:28': 'Intel', 'A4:4E:31': 'Intel', 'B4:6B:FC': 'Intel', 'C8:D0:83': 'Intel',
"00:1B:21": "Intel",
"00:1C:C0": "Intel",
"00:1E:64": "Intel",
"00:21:5C": "Intel",
"08:D4:0C": "Intel",
"18:1D:EA": "Intel",
"34:02:86": "Intel",
"40:74:E0": "Intel",
"48:51:B7": "Intel",
"58:A0:23": "Intel",
"64:D4:DA": "Intel",
"80:19:34": "Intel",
"8C:8D:28": "Intel",
"A4:4E:31": "Intel",
"B4:6B:FC": "Intel",
"C8:D0:83": "Intel",
# Qualcomm/Atheros
'00:03:7F': 'Qualcomm', '00:24:E4': 'Qualcomm', '04:F0:21': 'Qualcomm',
'1C:4B:D6': 'Qualcomm', '88:71:B1': 'Qualcomm', 'A0:65:18': 'Qualcomm',
"00:03:7F": "Qualcomm",
"00:24:E4": "Qualcomm",
"04:F0:21": "Qualcomm",
"1C:4B:D6": "Qualcomm",
"88:71:B1": "Qualcomm",
"A0:65:18": "Qualcomm",
# Broadcom
'00:10:18': 'Broadcom', '00:1A:2B': 'Broadcom', '20:10:7A': 'Broadcom',
"00:10:18": "Broadcom",
"00:1A:2B": "Broadcom",
"20:10:7A": "Broadcom",
# Realtek
'00:0A:EB': 'Realtek', '00:E0:4C': 'Realtek', '48:02:2A': 'Realtek',
'52:54:00': 'Realtek', '80:EA:96': 'Realtek',
"00:0A:EB": "Realtek",
"00:E0:4C": "Realtek",
"48:02:2A": "Realtek",
"52:54:00": "Realtek",
"80:EA:96": "Realtek",
# Logitech
'00:1F:20': 'Logitech', '34:88:5D': 'Logitech', '6C:B7:49': 'Logitech',
"00:1F:20": "Logitech",
"34:88:5D": "Logitech",
"6C:B7:49": "Logitech",
# Lenovo
'00:09:2D': 'Lenovo', '28:D2:44': 'Lenovo', '54:EE:75': 'Lenovo', '98:FA:9B': 'Lenovo',
"00:09:2D": "Lenovo",
"28:D2:44": "Lenovo",
"54:EE:75": "Lenovo",
"98:FA:9B": "Lenovo",
# Dell
'00:14:22': 'Dell', '00:1A:A0': 'Dell', '18:DB:F2': 'Dell', '34:17:EB': 'Dell',
'78:2B:CB': 'Dell', 'A4:BA:DB': 'Dell', 'E4:B9:7A': 'Dell',
"00:14:22": "Dell",
"00:1A:A0": "Dell",
"18:DB:F2": "Dell",
"34:17:EB": "Dell",
"78:2B:CB": "Dell",
"A4:BA:DB": "Dell",
"E4:B9:7A": "Dell",
# HP
'00:0F:61': 'HP', '00:14:C2': 'HP', '10:1F:74': 'HP', '28:80:23': 'HP',
'38:63:BB': 'HP', '5C:B9:01': 'HP', '80:CE:62': 'HP', 'A0:D3:C1': 'HP',
"00:0F:61": "HP",
"00:14:C2": "HP",
"10:1F:74": "HP",
"28:80:23": "HP",
"38:63:BB": "HP",
"5C:B9:01": "HP",
"80:CE:62": "HP",
"A0:D3:C1": "HP",
# Tile
'F8:E4:E3': 'Tile', 'C4:E7:BE': 'Tile', 'DC:54:D7': 'Tile', 'E4:B0:21': 'Tile',
"F8:E4:E3": "Tile",
"C4:E7:BE": "Tile",
"DC:54:D7": "Tile",
"E4:B0:21": "Tile",
# Raspberry Pi
'B8:27:EB': 'Raspberry Pi', 'DC:A6:32': 'Raspberry Pi', 'E4:5F:01': 'Raspberry Pi',
"B8:27:EB": "Raspberry Pi",
"DC:A6:32": "Raspberry Pi",
"E4:5F:01": "Raspberry Pi",
# Amazon
'00:FC:8B': 'Amazon', '10:CE:A9': 'Amazon', '34:D2:70': 'Amazon', '40:B4:CD': 'Amazon',
'44:65:0D': 'Amazon', '68:54:FD': 'Amazon', '74:C2:46': 'Amazon', '84:D6:D0': 'Amazon',
'A0:02:DC': 'Amazon', 'AC:63:BE': 'Amazon', 'B4:7C:9C': 'Amazon', 'FC:65:DE': 'Amazon',
"00:FC:8B": "Amazon",
"10:CE:A9": "Amazon",
"34:D2:70": "Amazon",
"40:B4:CD": "Amazon",
"44:65:0D": "Amazon",
"68:54:FD": "Amazon",
"74:C2:46": "Amazon",
"84:D6:D0": "Amazon",
"A0:02:DC": "Amazon",
"AC:63:BE": "Amazon",
"B4:7C:9C": "Amazon",
"FC:65:DE": "Amazon",
# Skullcandy
'00:01:00': 'Skullcandy', '88:E6:03': 'Skullcandy',
"00:01:00": "Skullcandy",
"88:E6:03": "Skullcandy",
# Bang & Olufsen
'00:21:3E': 'Bang & Olufsen', '78:C5:E5': 'Bang & Olufsen',
"00:21:3E": "Bang & Olufsen",
"78:C5:E5": "Bang & Olufsen",
# Audio-Technica
'A0:E9:DB': 'Audio-Technica', 'EC:81:93': 'Audio-Technica',
"A0:E9:DB": "Audio-Technica",
"EC:81:93": "Audio-Technica",
# Plantronics/Poly
'00:1D:DF': 'Plantronics', 'B0:B4:48': 'Plantronics', 'E8:FC:AF': 'Plantronics',
"00:1D:DF": "Plantronics",
"B0:B4:48": "Plantronics",
"E8:FC:AF": "Plantronics",
# Anker
'AC:89:95': 'Anker', 'E8:AB:FA': 'Anker',
"AC:89:95": "Anker",
"E8:AB:FA": "Anker",
# Misc/Generic
'00:00:0A': 'Omron', '00:1A:7D': 'Cyber-Blue', '00:1E:3D': 'Alps Electric',
'00:0B:57': 'Silicon Wave', '00:02:72': 'CC&C',
"00:00:0A": "Omron",
"00:1A:7D": "Cyber-Blue",
"00:1E:3D": "Alps Electric",
"00:0B:57": "Silicon Wave",
"00:02:72": "CC&C",
}
# Try to load from external file (easier to update)
+50 -32
View File
@@ -1,32 +1,50 @@
# TLE data for satellite tracking (updated periodically)
# To update: click "Update TLE" in satellite dashboard or SSTV mode
# Data source: CelesTrak (celestrak.org)
TLE_SATELLITES = {
'ISS': ('ISS (ZARYA)',
'1 25544U 98067A 25029.51432176 .00020818 00000+0 36919-3 0 9991',
'2 25544 51.6400 157.5640 0002671 123.5041 236.6291 15.49988902492099'),
'NOAA-15': ('NOAA 15',
'1 25338U 98030A 25028.84157420 .00000535 00000+0 26168-3 0 9999',
'2 25338 98.5676 356.1853 0009968 282.2567 77.7505 14.26225252390049'),
'NOAA-18': ('NOAA 18',
'1 28654U 05018A 25028.87364583 .00000454 00000+0 25082-3 0 9996',
'2 28654 98.8801 59.1618 0013609 281.7181 78.2479 14.13003043 24668'),
'NOAA-19': ('NOAA 19',
'1 33591U 09005A 25028.82370718 .00000425 00000+0 24556-3 0 9998',
'2 33591 99.0905 25.2347 0013428 265.3457 94.6190 14.13019285827447'),
'NOAA-20': ('NOAA 20 (JPSS-1)',
'1 43013U 17073A 25028.83917428 .00000284 00000+0 15698-3 0 9995',
'2 43013 98.7104 59.9558 0001165 102.5891 257.5432 14.19571458378899'),
'NOAA-21': ('NOAA 21 (JPSS-2)',
'1 54234U 22150A 25028.86292604 .00000268 00000+0 14911-3 0 9995',
'2 54234 98.7064 59.6648 0001271 88.4689 271.6646 14.19545810114699'),
'METEOR-M2': ('METEOR-M 2',
'1 40069U 14037A 25028.47802083 .00000099 00000+0 69422-4 0 9990',
'2 40069 98.4752 356.8632 0003942 251.7291 108.3489 14.20719440555299'),
'METEOR-M2-3': ('METEOR-M2 3',
'1 57166U 23091A 25028.81539352 .00000157 00000+0 94432-4 0 9993',
'2 57166 98.7690 91.9652 0001790 107.4859 252.6519 14.23646028 77844'),
'METEOR-M2-4': ('METEOR-M2 4',
'1 59051U 24039A 26061.19281216 .00000032 00000+0 34037-4 0 9998',
'2 59051 98.6892 21.9068 0008025 115.2158 244.9852 14.22415711104050'),
}
# TLE data for satellite tracking (updated periodically)
# To update: click "Update TLE" in satellite dashboard or SSTV mode
# Data source: CelesTrak (celestrak.org)
TLE_SATELLITES = {
"ISS": (
"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",
),
"NOAA-15": (
"NOAA 15",
"1 25338U 98030A 25028.84157420 .00000535 00000+0 26168-3 0 9999",
"2 25338 98.5676 356.1853 0009968 282.2567 77.7505 14.26225252390049",
),
"NOAA-18": (
"NOAA 18",
"1 28654U 05018A 25028.87364583 .00000454 00000+0 25082-3 0 9996",
"2 28654 98.8801 59.1618 0013609 281.7181 78.2479 14.13003043 24668",
),
"NOAA-19": (
"NOAA 19",
"1 33591U 09005A 25028.82370718 .00000425 00000+0 24556-3 0 9998",
"2 33591 99.0905 25.2347 0013428 265.3457 94.6190 14.13019285827447",
),
"NOAA-20": (
"NOAA 20 (JPSS-1)",
"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 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 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 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 26141.24240655 .00000007 00000+0 22827-4 0 9991",
"2 59051 98.6997 100.8818 0005969 244.5272 115.5289 14.22426426115439",
),
}
+18 -14
View File
@@ -6,17 +6,15 @@
# Basic usage (build locally):
# docker compose --profile basic up -d --build
#
# Basic usage (pre-built image from registry):
# INTERCEPT_IMAGE=ghcr.io/user/intercept:latest docker compose --profile basic up -d
#
# With ADS-B history (Postgres):
# docker compose --profile history up -d
services:
intercept:
# When INTERCEPT_IMAGE is set, use that pre-built image; otherwise build locally
image: ${INTERCEPT_IMAGE:-intercept:latest}
# Always build and use the local image
image: intercept:latest
build: .
pull_policy: never
container_name: intercept
ports:
- "5050:5050"
@@ -28,8 +26,12 @@ services:
devices:
- /dev/bus/usb:/dev/bus/usb
volumes:
# Persist decoded images and database across container rebuilds
- ./data:/app/data
# Persist runtime output directories across container rebuilds.
# Mount subdirectories individually so Python modules in /app/data are not shadowed.
- ./data/weather_sat:/app/data/weather_sat
- ./data/radiosonde:/app/data/radiosonde
- ./data/subghz:/app/data/subghz
- ./data/adsb:/app/data/adsb
# Optional: mount logs directory
# - ./logs:/app/logs
environment:
@@ -68,9 +70,10 @@ services:
# ADS-B history with Postgres persistence
# Enable with: docker compose --profile history up -d
intercept-history:
# Same image/build fallback pattern as above
image: ${INTERCEPT_IMAGE:-intercept:latest}
# Always build and use the local image
image: intercept:latest
build: .
pull_policy: never
container_name: intercept-history
profiles:
- history
@@ -86,7 +89,10 @@ services:
devices:
- /dev/bus/usb:/dev/bus/usb
volumes:
- ./data:/app/data
- ./data/weather_sat:/app/data/weather_sat
- ./data/radiosonde:/app/data/radiosonde
- ./data/subghz:/app/data/subghz
- ./data/adsb:/app/data/adsb
environment:
- TZ=${TZ:-UTC}
- INTERCEPT_HOST=0.0.0.0
@@ -105,6 +111,8 @@ services:
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
# Disable login auth (set to true for local/dev use)
- INTERCEPT_DISABLE_AUTH=${INTERCEPT_DISABLE_AUTH:-false}
# Default observer coordinates (set to your location to skip the GPS prompt)
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
@@ -135,7 +143,3 @@ services:
interval: 10s
timeout: 5s
retries: 5
# Optional: Add volume for persistent SQLite database
# volumes:
# intercept-data:
+36
View File
@@ -354,6 +354,42 @@ Technical Surveillance Countermeasures (TSCM) screening for detecting wireless s
- No cryptographic de-randomization
- Passive screening only (no active probing by default)
## Drone Intelligence
Multi-vector UAV detection and identification system combining three complementary detection methods into unified contact tracking.
### Detection Vectors
- **Remote ID (WiFi/BLE)** — Parses ASTM F3411-22a broadcast frames from WiFi Beacon and BLE Advertisement packets. Extracts drone ID, operator ID, drone type, GPS position, altitude, speed, and emergency status. Mandatory for all drones >250g in the US/EU since 2023.
- **RTL-SDR RF (433/868 MHz)** — Monitors ISM bands for control link and telemetry signals characteristic of consumer and FPV drones. Detects DJI OcuSync, FrSky, FlySky, and generic FSK/GFSK drone control protocols.
- **HackRF (2.4/5.8 GHz)** — Wide-scan of video downlink and telemetry bands used by most consumer drones. Detects power above noise floor across 2.4002.483 GHz and 5.7255.875 GHz ISM bands.
### Contact Correlation
The `DroneCorrelator` merges raw observations from all three vectors into unified `DroneContact` objects:
- **TTL-based store** — contacts expire after 120 seconds of no activity
- **Multi-vector fusion** — a single contact can be seen on 13 vectors simultaneously
- **Deduplication** — observations from the same vector within 5 seconds are collapsed
### Risk Scoring
| Level | Criteria |
|-------|----------|
| High | No Remote ID broadcast (non-compliant) or ASTM non-conformant frame |
| Medium | Multiple detection vectors active, or RSSI delta >15 dB between vectors |
| Low | Compliant Remote ID present, single detection vector |
### Live Map
Remote ID contacts with GPS position data are plotted on a Leaflet map. Markers show drone ID and last known coordinates. Map updates in real time via SSE.
### Requirements
- WiFi adapter capable of monitor mode (for BLE/WiFi Remote ID)
- RTL-SDR dongle (for 433/868 MHz RF detection)
- HackRF One (optional, for 2.4/5.8 GHz detection)
- Python package: `opendroneid>=1.0`
## Meshtastic Mesh Networks
Integration with Meshtastic LoRa mesh networking devices for decentralized communication.
+173
View File
@@ -446,6 +446,35 @@ Digital Selective Calling monitoring runs alongside AIS:
- Full functionality requires WiFi adapter, Bluetooth adapter, and SDR hardware
- Threat detection uses a database of 47K+ known tracker fingerprints
## Drone Intelligence
1. **Open Mode** - Select "Drone Intel" from the Intel group in the navigation bar
2. **Configure Interfaces** - Enter your WiFi interface name (must support monitor mode) for Remote ID detection
3. **Set RTL-SDR Index** - If you have multiple RTL-SDR devices, enter the device index (default: 0)
4. **Start** - Click "Start Scan" to activate all available detection vectors simultaneously
5. **Monitor Contacts** - Detected drone contacts appear in the contact list with ID, vectors, risk level, and last seen time
6. **View Map** - Contacts with GPS data from Remote ID are plotted on the live map
### Detection Vectors
- **Remote ID (WiFi/BLE)** — Passive sniff of 802.11 beacon frames and BLE advertisements. Decodes ASTM F3411 payloads: drone GPS, operator ID, drone type, speed, altitude, and emergency status
- **433/868 MHz RF** — RTL-SDR scans ISM bands for drone control link and telemetry RF signatures
- **2.4/5.8 GHz** — HackRF (if present) sweeps video downlink bands for active drone transmissions
### Risk Levels
- **High** — Drone operating without Remote ID (non-compliant) or malformed ASTM frame. Warrants immediate attention.
- **Medium** — Contact detected on multiple RF vectors, or significant RSSI difference between vectors (>15 dB). May indicate evasion or multi-radio platform.
- **Low** — Compliant Remote ID broadcast, single detection vector. Standard consumer drone.
### Tips
- Remote ID is mandatory for drones >250g in the US (FAA) and EU (EU 2019/945) — absence of Remote ID is itself a significant indicator
- WiFi adapter must support monitor mode; run `airmon-ng check kill` if other processes interfere
- The contact map only shows drones that broadcast GPS coordinates via Remote ID
- Contacts expire after 120 seconds of inactivity — the list shows only currently active drones
- HackRF detection is passive (receive-only); no transmission occurs
## Spy Stations
1. **Browse Database** - View the full list of documented number stations and diplomatic networks
@@ -539,6 +568,150 @@ Enable "Show All Agents" to aggregate data from all registered agents simultaneo
For complete documentation, see [Distributed Agents Guide](DISTRIBUTED_AGENTS.md).
## Webhooks & Notifications
INTERCEPT has a built-in alert engine that fires webhooks when decoded events match configurable rules. This lets you forward pager messages (or events from any other mode) to Discord, Slack, n8n, Home Assistant, or any HTTP endpoint.
### How it works
1. You configure **alert rules** via the Alerts UI — each rule defines which mode and event type to watch, optional match criteria, and a severity level.
2. When an incoming event matches a rule, INTERCEPT stores it in the alert log and POSTs a JSON payload to your configured webhook URL.
3. All modes are supported: pager, sensor, ADS-B, AIS, ACARS, WiFi, Bluetooth, and more.
### Enable the webhook
Set these environment variables in your `.env` file or `docker-compose.yml`:
| Variable | Default | Description |
|----------|---------|-------------|
| `ALERT_WEBHOOK_URL` | _(empty)_ | URL to POST alert payloads to |
| `ALERT_WEBHOOK_SECRET` | _(empty)_ | Optional token sent as `X-Alert-Token` header |
| `ALERT_WEBHOOK_TIMEOUT` | `5` | HTTP timeout in seconds |
**Local install (`.env`):**
```env
ALERT_WEBHOOK_URL=https://your-endpoint.example.com/intercept-alerts
ALERT_WEBHOOK_SECRET=mysecrettoken
```
**Docker (`.env` or `docker-compose.yml` environment block):**
```env
ALERT_WEBHOOK_URL=https://your-endpoint.example.com/intercept-alerts
ALERT_WEBHOOK_SECRET=mysecrettoken
```
### Create an alert rule
1. Open the **Alerts** panel in INTERCEPT
2. Click **New Rule**
3. Configure:
- **Mode**: `pager` (or any other mode, or leave blank to match all)
- **Event type**: `message` for pager decodes (or blank to match all event types)
- **Match criteria**: leave empty to forward everything, or add filters (e.g. capcode equals `1234567`, or message contains `FIRE`)
- **Severity**: `low`, `medium`, or `high`
4. Save and enable the rule
### Webhook payload format
INTERCEPT sends a POST request with `Content-Type: application/json`:
```json
{
"id": 42,
"rule_id": 1,
"mode": "pager",
"event_type": "message",
"severity": "medium",
"title": "My Pager Rule",
"message": "message | 1234567",
"created_at": "2026-04-13T10:00:00+00:00",
"payload": {
"mode": "pager",
"event_type": "message",
"event": {
"capcode": "1234567",
"message": "UNIT 4 RESPOND TO 123 MAIN ST",
"type": "POCSAG1200"
},
"rule": { "id": 1, "name": "My Pager Rule" }
}
}
```
### Sending to Discord
Discord webhooks expect a specific JSON format (`content`, `embeds`), so you need a small relay between INTERCEPT and Discord. Two options:
**Option A — No-code relay (recommended)**
Use [n8n](https://n8n.io), [Make](https://make.com), or [Pipedream](https://pipedream.com) to receive INTERCEPT's webhook and forward it to Discord with a custom message template. Point `ALERT_WEBHOOK_URL` at your workflow's ingest URL.
**Option B — Self-hosted Python relay**
Save this as `discord_relay.py` and run it alongside INTERCEPT:
```python
from flask import Flask, request
import urllib.request, json
app = Flask(__name__)
DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN"
@app.post("/relay")
def relay():
data = request.get_json(force=True)
mode = data.get("mode", "unknown").upper()
title = data.get("title", "Alert")
message = data.get("message", "")
event = data.get("payload", {}).get("event", {})
# Build a readable Discord message
lines = [f"**[{mode}]** {title}", message]
if event.get("capcode"):
lines.append(f"Capcode: `{event['capcode']}`")
if event.get("type"):
lines.append(f"Protocol: {event['type']}")
payload = json.dumps({"content": "\n".join(lines)}).encode()
req = urllib.request.Request(
DISCORD_WEBHOOK_URL,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
urllib.request.urlopen(req, timeout=5)
return "", 204
app.run(host="0.0.0.0", port=5051)
```
Then set:
```env
ALERT_WEBHOOK_URL=http://localhost:5051/relay
```
Run the relay: `python3 discord_relay.py`
The relay formats pager decodes as Discord messages like:
```
[PAGER] My Pager Rule
message | 1234567
Capcode: `1234567`
Protocol: POCSAG1200
```
### Filtering specific capcodes
To only forward decodes from a specific capcode, set the rule's **Match criteria**:
| Field | Operator | Value |
|-------|----------|-------|
| `capcode` | equals | `1234567` |
Multiple rules can coexist — e.g. one rule for all pager traffic to a general Discord channel, and a second rule for emergency capcodes with `high` severity to a separate channel (using a second relay instance on a different port).
## Configuration
INTERCEPT can be configured via environment variables:
@@ -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
+6 -1
View File
@@ -36,7 +36,7 @@
</div>
<div class="hero-stats">
<div class="stat">
<span class="stat-value">34</span>
<span class="stat-value">35</span>
<span class="stat-label">Modes</span>
</div>
<div class="stat">
@@ -202,6 +202,11 @@
<h3>TSCM</h3>
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
</div>
<div class="feature-card" data-category="intel">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="18" r="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><line x1="8" y1="8" x2="9" y2="9"/><line x1="16" y1="8" x2="15" y2="9"/><line x1="8" y1="16" x2="9" y2="15"/><line x1="16" y1="16" x2="15" y2="15"/></svg></div>
<h3>Drone Intelligence</h3>
<p>Multi-vector UAV detection via ASTM F3411 Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF fingerprinting, and HackRF 2.4/5.8 GHz scanning with live contact map and risk scoring.</p>
</div>
<div class="feature-card" data-category="wireless">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
<h3>Meshtastic</h3>
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
# Meshcore Support — Design Spec
**Date:** 2026-05-10
**Status:** Approved
## Overview
Add a Meshcore mode to Intercept, providing full feature parity with the existing Meshtastic module. Meshcore is a LoRa mesh radio platform using a repeater-based routing model (dedicated infrastructure nodes relay; clients do not). It has an official Python library (`meshcore`, PyPI) and a published companion protocol.
## Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Connection methods | USB serial + TCP + BLE | Maximum hardware flexibility |
| Feature scope | Full parity with Meshtastic | Messages, node map, telemetry, traceroute, repeater management |
| Async integration | Background asyncio thread | meshcore library is asyncio-based; this isolates it cleanly from Flask/gevent |
| UI layout | Messages-first (mirror Meshtastic) | Sidebar: contacts/nodes. Center: message feed. Tabs: map, telemetry, repeaters |
| BLE in Docker | Document limitation + proxy workaround | BLE unavailable in containers; meshcore-proxy bridges BLE → TCP |
## Architecture
### New Files
```
utils/meshcore.py # MeshcoreClient singleton + dataclasses
utils/meshcore_client.py # Thin async wrapper around meshcore library (lives in asyncio thread)
routes/meshcore.py # Flask blueprint (/meshcore)
static/js/modes/meshcore.js # Frontend IIFE module
static/css/modes/meshcore.css # Scoped styles
templates/partials/modes/meshcore.html # Sidebar partial
tests/test_meshcore_client.py
tests/test_meshcore_routes.py
tests/test_meshcore_integration.py
```
### Modified Files
- `routes/__init__.py` — import + `register_blueprint(meshcore_bp)`
- `templates/index.html` — ~12 insertion points (CSS, partial, JS, validModes, modeGroups, etc.)
- `requirements.txt` — add `meshcore>=1.0.0` (optional dep, graceful fallback if absent)
- `.gitignore` — already has `.superpowers/`
### Async Bridge Pattern
```
meshcore library (asyncio event loop in daemon OS thread)
→ event callbacks (_on_message, _on_node_update, _on_telemetry)
→ asyncio.run_coroutine_threadsafe() → queue.Queue (thread-safe, max 500)
→ /meshcore/stream SSE generator drains queue (30s keepalive timeout)
→ Frontend EventSource routes by event type
```
This is the same conceptual pattern as all other decoder integrations in Intercept (ADS-B socket reader, AIS-catcher output thread, rtl_433 stdout thread), just with an explicit asyncio loop instead of a subprocess thread.
## Data Model
```python
@dataclass
class MeshcoreMessage:
id: str
sender_id: str
recipient_id: str # node ID or broadcast address
text: str
timestamp: datetime
hop_count: int
snr: float | None
is_direct: bool # DM vs broadcast
pending: bool = False # optimistic send state
@dataclass
class MeshcoreNode:
node_id: str
name: str
is_repeater: bool # key Meshcore distinction — rendered differently on map
lat: float | None
lon: float | None
battery_pct: int | None
last_seen: datetime
snr: float | None
hops_away: int | None
@dataclass
class MeshcoreContact:
node_id: str
name: str
public_key: str # Meshcore uses key-based addressing
last_msg: datetime | None
@dataclass
class MeshcoreTelemetry:
node_id: str
timestamp: datetime
battery_pct: int | None
voltage: float | None
temperature: float | None
humidity: float | None
uptime_secs: int | None
@dataclass
class MeshcoreTraceroute:
origin_id: str
destination_id: str
hops: list[str]
snr_per_hop: list[float]
timestamp: datetime
@dataclass
class SerialConfig:
port: str | None = None # None = auto-discover
baud: int = 115200
@dataclass
class TCPConfig:
host: str = "localhost"
port: int = 5000 # meshcore-proxy default
@dataclass
class BLEConfig:
device_address: str | None = None # None = scan for first Meshcore device
ConnectionConfig = SerialConfig | TCPConfig | BLEConfig
```
Connection state enum: `DISCONNECTED | CONNECTING | CONNECTED | ERROR`
## Connection Handling
### Serial
Auto-discover: scan `/dev/ttyUSB*`, `/dev/ttyACM*`, `/dev/cu.usbserial*` and return list to frontend via `GET /meshcore/ports`. User can also specify path directly.
### TCP
Direct connection to `host:port`. Primary use case: meshcore-proxy running on the host, exposing a local USB or BLE device over TCP for Docker deployments.
### BLE
- Linux/RPi: meshcore library uses BlueZ (requires `bluetoothctl` accessible)
- macOS: meshcore library uses CoreBluetooth
- Docker: detect via presence of `/.dockerenv` or `INTERCEPT_DOCKER=1` env var; connect attempt fails fast with clear error directing user to meshcore-proxy
`GET /meshcore/ble/scan` returns: `[{"address": "AA:BB:CC:DD:EE:FF", "name": "MeshCore-Node1", "rssi": -72}]`
### Reconnect
Exponential backoff: 3 retries at 5s, 15s, 45s (cap 60s). On final failure, pushes `status` SSE event with `state: "error"`. User can manually retry via `POST /meshcore/connect`.
## API Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /meshcore/status | Connection state + transport info |
| POST | /meshcore/connect | Connect with SerialConfig, TCPConfig, or BLEConfig |
| POST | /meshcore/disconnect | Disconnect and stop background thread |
| GET | /meshcore/ports | List available serial ports |
| GET | /meshcore/ble/scan | Scan for nearby Meshcore BLE devices |
| GET | /meshcore/stream | SSE stream (messages, nodes, telemetry, status) |
| GET | /meshcore/messages | Recent messages (last 500) |
| POST | /meshcore/send | Send text message |
| GET | /meshcore/nodes | All known nodes |
| GET | /meshcore/contacts | Contact list |
| POST | /meshcore/contacts | Add contact |
| DELETE | /meshcore/contacts/`<id>` | Remove contact |
| GET | /meshcore/telemetry/`<node_id>` | Telemetry history for node |
| POST | /meshcore/traceroute | Request traceroute to node |
| GET | /meshcore/repeaters | List repeater nodes |
## SSE Event Format
```json
{"type": "message", "data": { ...MeshcoreMessage }}
{"type": "node", "data": { ...MeshcoreNode }}
{"type": "telemetry", "data": { ...MeshcoreTelemetry }}
{"type": "traceroute", "data": { ...MeshcoreTraceroute }}
{"type": "status", "data": {"state": "connected", "transport": "serial", "device": "/dev/ttyUSB0"}}
```
Keepalive comment (`: keepalive`) sent every 30 seconds on idle.
## Frontend (meshcore.js)
IIFE pattern, same as all other Intercept JS modules. Key responsibilities:
- **SSE consumer** — `EventSource('/meshcore/stream')`, routes events by `type`
- **Message feed** — append to scrolling list, optimistic pending state on send
- **Sidebar** — contact list + node list; repeaters shown separately with triangle icon (vs circle for client nodes), matching Meshcore UI conventions
- **Tabs** — Map (Leaflet, reuse existing map setup pattern), Telemetry (Chart.js, reuse existing chart helpers), Repeaters (dedicated table view)
- **Connection panel** — transport selector (Serial / TCP / BLE), port/IP/address input, connect/disconnect button
- **Traceroute modal** — hop diagram with SNR annotations, same visual style as Meshtastic traceroute
## Repeater Management
Meshcore repeaters are a first-class concept (unlike Meshtastic where all nodes relay). Design:
- Repeaters identified by `is_repeater: true` on `MeshcoreNode`
- Rendered on map as orange triangles (client nodes = blue circles)
- Dedicated "Repeaters" tab in the main panel showing: name, location, uptime, last seen, hop count
- Repeater stats surfaced in telemetry if available (uptime_secs from `MeshcoreTelemetry`)
## Error Handling
- meshcore library not installed → mode loads but shows "meshcore package required: `pip install meshcore`"
- BLE in Docker → clear error: "BLE unavailable in Docker. Run meshcore-proxy on the host and connect via TCP."
- Serial port not found → return available ports list in error response
- Connection lost mid-session → automatic reconnect with backoff; SSE `status` event updates UI indicator
- Send failure → SSE event clears pending state, shows error in message feed
## Testing
**`tests/test_meshcore_client.py`**
- Connection state machine transitions
- Reconnect backoff timing (mock asyncio loop)
- Message parsing and queue feeding
- Node/contact TTL expiry
- BLE unavailability error (Docker scenario)
**`tests/test_meshcore_routes.py`**
- All REST endpoints: correct JSON shape, status codes
- `/meshcore/connect` with each connection config type
- `/meshcore/send` with missing/invalid params → 400
- SSE stream yields keepalive on empty queue
- Input validation via `utils/validation.py`
**`tests/test_meshcore_integration.py`**
- Mock meshcore library at boundary (same approach as mocking meshtastic SDK)
- Full round-trip: connect → receive message event → appears in SSE stream
- Traceroute request → hop structure correctly parsed
## Dependencies
```
meshcore>=1.0.0 # optional — graceful degradation if absent
```
No new frontend dependencies — Leaflet and Chart.js already present.
## Reference
- Meshcore Python library: https://github.com/meshcore-dev/meshcore_py
- Companion protocol: https://docs.meshcore.io/companion_protocol/
- meshcore-proxy (BLE/serial → TCP bridge): https://github.com/rgregg/meshcore-proxy
- Existing Meshtastic implementation (reference): `utils/meshtastic.py`, `routes/meshtastic.py`
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
"""Minimal Flask-SocketIO compatibility shim.
This is only intended to satisfy radiosonde_auto_rx's optional web UI
dependency in environments where ``flask_socketio`` is not installed.
It provides the small subset of the API that auto_rx imports.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
class SocketIO:
"""Very small subset of Flask-SocketIO's SocketIO interface."""
def __init__(self, app, async_mode: str | None = None, *args, **kwargs):
self.app = app
self.async_mode = async_mode or "threading"
self._handlers: dict[tuple[str, str | None], Callable[..., Any]] = {}
def on(self, event: str, namespace: str | None = None):
"""Register an event handler decorator."""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
self._handlers[(event, namespace)] = func
return func
return decorator
def emit(self, event: str, data: Any = None, namespace: str | None = None, *args, **kwargs) -> None:
"""No-op emit used when the real Socket.IO server is unavailable."""
return None
def run(self, app=None, host: str = "127.0.0.1", port: int = 5000, *args, **kwargs) -> None:
"""Fallback to Flask's built-in development server."""
flask_app = app or self.app
flask_app.run(
host=host,
port=port,
threaded=True,
use_reloader=False,
)
-210
View File
@@ -1,210 +0,0 @@
DMSP 5D-3 F16 (USA 172)
1 28054U 03048A 26037.66410905 .00000171 00000+0 11311-3 0 9991
2 28054 99.0018 60.5544 0007736 150.6435 318.8272 14.14449870151032
METEOSAT-9 (MSG-2)
1 28912U 05049B 26037.20698824 .00000122 00000+0 00000+0 0 9990
2 28912 9.0646 55.4438 0001292 220.3216 340.7358 1.00280364 5681
DMSP 5D-3 F17 (USA 191)
1 29522U 06050A 26037.63495522 .00000221 00000+0 13641-3 0 9997
2 29522 98.7406 46.8646 0011088 71.3269 288.9107 14.14949568993957
FENGYUN 3A
1 32958U 08026A 26037.29889977 .00000162 00000+0 97205-4 0 9995
2 32958 98.6761 340.6748 0009336 139.4536 220.7337 14.19536323916838
GOES 14
1 35491U 09033A 26037.59737599 .00000128 00000+0 00000+0 0 9998
2 35491 1.3510 84.7861 0001663 279.3774 203.6871 1.00112472 5283
DMSP 5D-3 F18 (USA 210)
1 35951U 09057A 26037.59574243 .00000344 00000+0 20119-3 0 9997
2 35951 98.8912 18.7405 0010014 262.2671 97.7365 14.14814612841124
EWS-G2 (GOES 15)
1 36411U 10008A 26037.42417604 .00000037 00000+0 00000+0 0 9998
2 36411 0.9477 85.6904 0004764 200.6178 64.5237 1.00275731 58322
COMS 1
1 36744U 10032A 26037.66884865 -.00000343 00000+0 00000+0 0 9998
2 36744 4.4730 77.2684 0001088 239.9858 188.4845 1.00274368 49786
FENGYUN 3B
1 37214U 10059A 26037.62488625 .00000510 00000+0 28715-3 0 9992
2 37214 98.9821 82.9728 0021838 194.4193 280.6049 14.14810700788968
SUOMI NPP
1 37849U 11061A 26037.58885771 .00000151 00000+0 92735-4 0 9993
2 37849 98.7835 339.4455 0001677 23.1332 336.9919 14.19534335739918
METEOSAT-10 (MSG-3)
1 38552U 12035B 26037.34062893 -.00000007 00000+0 00000+0 0 9993
2 38552 4.3618 61.5789 0002324 286.1065 271.3938 1.00272839 49549
METOP-B
1 38771U 12049A 26037.61376690 .00000161 00000+0 93652-4 0 9994
2 38771 98.6708 91.6029 0002456 28.4142 331.7169 14.21434029694718
INSAT-3D
1 39216U 13038B 26037.58021591 -.00000338 00000+0 00000+0 0 9998
2 39216 1.5890 84.3012 0001719 220.0673 170.6954 1.00273812 45771
FENGYUN 3C
1 39260U 13052A 26037.57879946 .00000181 00000+0 11337-3 0 9991
2 39260 98.4839 17.5531 0015475 42.6626 317.5748 14.15718213640089
METEOR-M 2
1 40069U 14037A 26037.57010537 .00000364 00000+0 18579-3 0 9995
2 40069 98.4979 18.0359 0006835 60.5067 299.6792 14.21415164600761
HIMAWARI-8
1 40267U 14060A 26037.58238259 -.00000273 00000+0 00000+0 0 9991
2 40267 0.0457 252.0286 0000958 31.3580 203.5957 1.00278490 41450
FENGYUN 2G
1 40367U 14090A 26037.64556289 -.00000299 00000+0 00000+0 0 9996
2 40367 5.3089 74.4184 0001565 198.1345 195.9683 1.00263067 40698
METEOSAT-11 (MSG-4)
1 40732U 15034A 26037.62779616 .00000065 00000+0 00000+0 0 9990
2 40732 2.8728 71.8294 0001180 241.7344 58.8290 1.00268087 5909
ELEKTRO-L 2
1 41105U 15074A 26037.40900929 -.00000118 00000+0 00000+0 0 9998
2 41105 6.3653 72.1489 0003612 229.0998 328.0297 1.00272232 37198
INSAT-3DR
1 41752U 16054A 26037.65505200 -.00000075 00000+0 00000+0 0 9997
2 41752 0.0554 93.8053 0013744 184.8269 167.9427 1.00271627 34504
HIMAWARI-9
1 41836U 16064A 26037.58238259 -.00000273 00000+0 00000+0 0 9990
2 41836 0.0124 137.0088 0001068 210.1850 139.9064 1.00271322 33905
GOES 16
1 41866U 16071A 26037.60517604 -.00000089 00000+0 00000+0 0 9993
2 41866 0.1490 94.1417 0002832 199.6896 316.0413 1.00271854 33798
FENGYUN 4A
1 41882U 16077A 26037.65041625 -.00000356 00000+0 00000+0 0 9994
2 41882 1.9907 81.7886 0006284 132.9819 279.8453 1.00276098 33627
CYGFM05
1 41884U 16078A 26037.42561482 .00027408 00000+0 46309-3 0 9992
2 41884 34.9596 42.6579 0007295 332.2973 27.7361 15.50585086508404
CYGFM04
1 41885U 16078B 26037.34428483 .00032519 00000+0 49575-3 0 9994
2 41885 34.9348 16.2836 0005718 359.2189 0.8525 15.53424088508589
CYGFM02
1 41886U 16078C 26037.35007768 .00035591 00000+0 50564-3 0 9998
2 41886 34.9436 13.7490 0006836 2.8379 357.2383 15.55324468508720
CYGFM01
1 41887U 16078D 26037.39685921 .00028560 00000+0 47572-3 0 9999
2 41887 34.9425 44.8029 0007415 323.1915 36.8298 15.50976884508344
CYGFM08
1 41888U 16078E 26037.34185185 .00031327 00000+0 49606-3 0 9997
2 41888 34.9457 27.4597 0008083 350.5361 9.5208 15.52364941508578
CYGFM07
1 41890U 16078G 26037.32199955 .00032204 00000+0 49829-3 0 9990
2 41890 34.9475 16.2411 0005914 7.0804 353.0002 15.53017084508593
CYGFM03
1 41891U 16078H 26037.35550653 .00031487 00000+0 48940-3 0 9995
2 41891 34.9430 17.9804 0005939 349.1458 10.9136 15.52895386508574
FENGYUN 3D
1 43010U 17072A 26037.62659924 .00000092 00000+0 65298-4 0 9990
2 43010 98.9980 9.7978 0002479 69.6779 290.4663 14.19704535426460
NOAA 20 (JPSS-1)
1 43013U 17073A 26037.60336371 .00000124 00000+0 79520-4 0 9999
2 43013 98.7658 338.3064 0000377 14.6433 345.4754 14.19527655425942
GOES 17
1 43226U 18022A 26037.60794939 -.00000180 00000+0 00000+0 0 9993
2 43226 0.6016 88.1527 0002754 213.0089 324.8756 1.00269924 29115
FENGYUN 2H
1 43491U 18050A 26037.66161282 -.00000125 00000+0 00000+0 0 9992
2 43491 2.6948 80.6967 0002145 171.8276 201.3055 1.00274855 28134
METOP-C
1 43689U 18087A 26037.63948662 .00000167 00000+0 96262-4 0 9998
2 43689 98.6834 99.5280 0001629 143.8933 216.2355 14.21510040376280
GEO-KOMPSAT-2A
1 43823U 18100A 26037.57995591 .00000000 00000+0 00000+0 0 9996
2 43823 0.0152 95.1913 0001141 313.4173 65.1318 1.00271011 26327
METEOR-M2 2
1 44387U 19038A 26037.58492015 .00000244 00000+0 12531-3 0 9993
2 44387 98.9044 23.0180 0002141 55.2566 304.8814 14.24320728342700
ARKTIKA-M 1
1 47719U 21016A 26035.90384421 -.00000136 00000+0 00000+0 0 9994
2 47719 63.1930 76.4940 7230705 269.3476 15.2984 2.00623094 36131
FENGYUN 3E
1 49008U 21062A 26037.62586080 .00000245 00000+0 13631-3 0 9992
2 49008 98.7499 42.4910 0002627 96.2819 263.8657 14.19890127238058
GOES 18
1 51850U 22021A 26037.59876267 .00000098 00000+0 00000+0 0 9999
2 51850 0.0198 91.3546 0000843 290.2366 193.6737 1.00273310 5288
NOAA 21 (JPSS-2)
1 54234U 22150A 26037.56792604 .00000152 00000+0 92800-4 0 9995
2 54234 98.7521 338.1972 0001388 169.8161 190.3044 14.19543641168012
METEOSAT-12 (MTG-I1)
1 54743U 22170C 26037.62580281 -.00000006 00000+0 00000+0 0 9990
2 54743 0.7119 25.1556 0002027 273.4388 63.0828 1.00270670 11667
TIANMU-1 03
1 55973U 23039A 26037.63298084 .00025307 00000+0 57478-3 0 9994
2 55973 97.5143 206.9374 0002852 198.5193 161.5950 15.43014921160671
TIANMU-1 04
1 55974U 23039B 26037.59957323 .00027172 00000+0 60888-3 0 9999
2 55974 97.5075 206.0729 0003605 196.0743 164.0390 15.43399931160675
TIANMU-1 05
1 55975U 23039C 26037.60840428 .00024975 00000+0 56836-3 0 9995
2 55975 97.5122 206.5750 0002421 224.3240 135.7814 15.42959696160653
TIANMU-1 06
1 55976U 23039D 26037.60004198 .00024821 00000+0 55598-3 0 9996
2 55976 97.5133 207.0788 0002810 218.0193 142.0857 15.43432906160673
FENGYUN 3G
1 56232U 23055A 26037.30935013 .00046475 00000+0 74423-3 0 9993
2 56232 49.9940 300.8928 0009962 237.3703 122.6303 15.52544991159665
METEOR-M2 3
1 57166U 23091A 26037.62090481 .00000022 00000+0 28455-4 0 9999
2 57166 98.6282 95.1607 0004003 174.5474 185.5750 14.24034408135931
TIANMU-1 07
1 57399U 23101A 26037.63242936 .00011510 00000+0 41012-3 0 9991
2 57399 97.2786 91.2606 0002747 218.4597 141.6448 15.29074661141694
TIANMU-1 08
1 57400U 23101B 26037.66743594 .00011474 00000+0 41016-3 0 9996
2 57400 97.2774 91.0783 0004440 227.8102 132.2762 15.28966110141699
TIANMU-1 09
1 57401U 23101C 26037.65072558 .00011360 00000+0 40433-3 0 9997
2 57401 97.2732 90.5514 0003773 229.5297 130.5615 15.29113177141698
TIANMU-1 10
1 57402U 23101D 26037.61974057 .00011836 00000+0 42113-3 0 9994
2 57402 97.2810 91.4302 0005461 233.7620 126.3116 15.29106286141685
FENGYUN 3F
1 57490U 23111A 26037.61228373 .00000135 00000+0 84019-4 0 9997
2 57490 98.6988 109.9815 0001494 99.6638 260.4707 14.19912110130332
ARKTIKA-M 2
1 58584U 23198A 26037.15964049 .00000160 00000+0 00000+0 0 9994
2 58584 63.2225 168.8508 6872222 267.8808 18.8364 2.00612776 15698
TIANMU-1 11
1 58645U 23205A 26037.58628093 .00009545 00000+0 37951-3 0 9999
2 58645 97.3574 61.2485 0010997 103.8713 256.3749 15.25445149117601
TIANMU-1 12
1 58646U 23205B 26037.61705312 .00010066 00000+0 40129-3 0 9995
2 58646 97.3561 61.0663 0009308 89.8253 270.4052 15.25355570117590
TIANMU-1 13
1 58647U 23205C 26037.64894829 .00010029 00000+0 39925-3 0 9992
2 58647 97.3589 61.3229 0009456 74.8265 285.4018 15.25403883117592
TIANMU-1 14
1 58648U 23205D 26037.63305929 .00009719 00000+0 38718-3 0 9993
2 58648 97.3523 60.6045 0010314 77.9995 282.2399 15.25381326117592
TIANMU-1 19
1 58660U 23208A 26037.58812600 .00016491 00000+0 58449-3 0 9991
2 58660 97.4377 153.5627 0006125 66.0574 294.1307 15.29155961117352
TIANMU-1 20
1 58661U 23208B 26037.59661536 .00016638 00000+0 56823-3 0 9990
2 58661 97.4315 154.0738 0008420 72.4906 287.7255 15.30347593117439
TIANMU-1 21
1 58662U 23208C 26037.56944589 .00017161 00000+0 55253-3 0 9998
2 58662 97.4367 156.2063 0008160 67.8039 292.4068 15.32247056117540
TIANMU-1 22
1 58663U 23208D 26037.59847459 .00015396 00000+0 55169-3 0 9994
2 58663 97.4371 153.6033 0005010 87.2275 272.9538 15.28818503117364
TIANMU-1 15
1 58700U 24004A 26037.63062994 .00009739 00000+0 38850-3 0 9991
2 58700 97.4651 223.9243 0008449 88.7599 271.4607 15.25356935115862
TIANMU-1 16
1 58701U 24004B 26037.61474986 .00010691 00000+0 42590-3 0 9993
2 58701 97.4590 223.2544 0006831 91.0928 269.1093 15.25387104115863
TIANMU-1 17
1 58702U 24004C 26037.59783649 .00011079 00000+0 44078-3 0 9994
2 58702 97.4624 223.6760 0006020 92.0871 268.1056 15.25425175115852
TIANMU-1 18
1 58703U 24004D 26037.64767373 .00010786 00000+0 42976-3 0 9996
2 58703 97.4642 223.9320 0005432 91.0134 269.1726 15.25387870115860
INSAT-3DS
1 58990U 24033A 26037.64159978 -.00000153 00000+0 00000+0 0 9998
2 58990 0.0277 242.2492 0001855 99.2205 108.3003 1.00271452 45758
METEOR-M2 4
1 59051U 24039A 26037.62796654 .00000070 00000+0 51194-4 0 9991
2 59051 98.6849 358.6843 0006923 178.9165 181.2029 14.22412185100701
GOES 19
1 60133U 24119A 26037.61098274 -.00000246 00000+0 00000+0 0 9996
2 60133 0.0027 288.6290 0001204 74.2636 278.5881 1.00270967 5651
FENGYUN 3H
1 65815U 25219A 26037.60879211 .00000151 00000+0 91464-4 0 9990
2 65815 98.6649 341.0050 0001596 86.5100 273.6260 14.19924132 18857
+1 -1
View File
@@ -3502,7 +3502,7 @@ class ModeManager:
stations_url = 'https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=tle'
satellites = load.tle_file(stations_url)
ts = load.timescale()
ts = load.timescale(builtin=True)
observer = Topos(latitude_degrees=lat, longitude_degrees=lon)
logger.info(f"Satellite predictor: {len(satellites)} satellites loaded")
+13 -4
View File
@@ -1,6 +1,6 @@
[project]
name = "intercept"
version = "2.26.5"
version = "2.27.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"
@@ -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>=1.0.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]
+1
View File
@@ -10,6 +10,7 @@ pytest-mock>=3.15.1
ruff>=0.1.0
black>=23.0.0
mypy>=1.0.0
pre-commit>=3.0.0
# Type stubs
types-flask>=1.1.0
+2
View File
@@ -27,6 +27,7 @@ pyserial>=3.5
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
meshtastic>=2.0.0
meshcore>=1.0.0
# Deauthentication attack detection (optional - for WiFi TSCM)
scapy>=2.4.5
@@ -45,6 +46,7 @@ cryptography>=41.0.0
# mypy>=1.0.0
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
flask-sock
simple-websocket>=0.5.1
websocket-client>=1.6.0
# System health monitoring (optional - graceful fallback if unavailable)
+8 -1
View File
@@ -18,9 +18,12 @@ def register_blueprints(app):
from .bt_locate import bt_locate_bp
from .controller import controller_bp
from .correlation import correlation_bp
from .drone import drone_bp
from .dsc import dsc_bp
from .gps import gps_bp
from .ground_station import ground_station_bp
from .listening_post import receiver_bp
from .meshcore import meshcore_bp
from .meshtastic import meshtastic_bp
from .meteor_websocket import meteor_bp
from .morse import morse_bp
@@ -68,6 +71,7 @@ def register_blueprints(app):
app.register_blueprint(correlation_bp)
app.register_blueprint(receiver_bp)
app.register_blueprint(meshtastic_bp)
app.register_blueprint(meshcore_bp)
app.register_blueprint(tscm_bp)
app.register_blueprint(spy_stations_bp)
app.register_blueprint(controller_bp) # Remote agent controller
@@ -89,6 +93,8 @@ def register_blueprints(app):
app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking
app.register_blueprint(system_bp) # System health monitoring
app.register_blueprint(ook_bp) # Generic OOK signal decoder
app.register_blueprint(ground_station_bp) # Ground station automation
app.register_blueprint(drone_bp) # Drone intelligence / UAV detection
# Exempt all API blueprints from CSRF (they use JSON, not form tokens)
if _csrf:
@@ -97,5 +103,6 @@ def register_blueprints(app):
# Initialize TSCM state with queue and lock from app
import app as app_module
if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
if hasattr(app_module, "tscm_queue") and hasattr(app_module, "tscm_lock"):
init_tscm_state(app_module.tscm_queue, app_module.tscm_lock)
+605 -465
View File
File diff suppressed because it is too large Load Diff
+42 -2
View File
@@ -15,7 +15,7 @@ import time
from flask import Blueprint, Response, jsonify, render_template, request
import app as app_module
from config import SHARED_OBSERVER_LOCATION_ENABLED
from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE, SHARED_OBSERVER_LOCATION_ENABLED
from utils.constants import (
AIS_RECONNECT_DELAY,
AIS_SOCKET_TIMEOUT,
@@ -408,11 +408,24 @@ def start_ais():
bias_t = data.get('bias_t', False)
tcp_port = AIS_TCP_PORT
# Optional UDP NMEA forwarding (e.g. for OpenCPN on port 10110)
udp_host = data.get('udp_host') or None
udp_port = None
if udp_host:
try:
udp_port = int(data.get('udp_port', 10110))
if not 1 <= udp_port <= 65535:
raise ValueError
except (TypeError, ValueError):
return api_error('Invalid udp_port (1-65535)', 400)
cmd = builder.build_ais_command(
device=sdr_device,
gain=float(gain),
bias_t=bias_t,
tcp_port=tcp_port
tcp_port=tcp_port,
udp_host=udp_host,
udp_port=udp_port,
)
# Use the found AIS-catcher path
@@ -535,6 +548,31 @@ def get_vessel_dsc(mmsi: str):
return api_success(data={'mmsi': mmsi, 'dsc_messages': matches})
@ais_bp.route('/vessels')
def ais_vessels():
"""Export current AIS vessel data as JSON.
Returns a snapshot of all tracked vessels suitable for integration
with external tools (OpenCPN, ship tracking apps, etc.).
Query parameters:
mmsi: Filter to a specific MMSI (optional)
Returns:
JSON with vessel list and metadata.
"""
vessels = dict(app_module.ais_vessels)
mmsi_filter = request.args.get('mmsi')
if mmsi_filter:
vessels = {k: v for k, v in vessels.items() if str(k) == str(mmsi_filter)}
return jsonify({
'count': len(vessels),
'vessels': list(vessels.values()),
})
@ais_bp.route('/dashboard')
def ais_dashboard():
"""Popout AIS dashboard."""
@@ -542,5 +580,7 @@ def ais_dashboard():
return render_template(
'ais_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
default_latitude=DEFAULT_LATITUDE,
default_longitude=DEFAULT_LONGITUDE,
embedded=embedded,
)
+31 -30
View File
@@ -2,74 +2,75 @@
from __future__ import annotations
from collections.abc import Generator
from flask import Blueprint, Response, request
from utils.alerts import get_alert_manager
from utils.responses import api_error, api_success
from utils.sse import format_sse
from utils.sse import sse_stream_fanout
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts")
@alerts_bp.route('/rules', methods=['GET'])
@alerts_bp.route("/rules", methods=["GET"])
def list_rules():
manager = get_alert_manager()
include_disabled = request.args.get('all') in ('1', 'true', 'yes')
return api_success(data={'rules': manager.list_rules(include_disabled=include_disabled)})
include_disabled = request.args.get("all") in ("1", "true", "yes")
return api_success(data={"rules": manager.list_rules(include_disabled=include_disabled)})
@alerts_bp.route('/rules', methods=['POST'])
@alerts_bp.route("/rules", methods=["POST"])
def create_rule():
data = request.get_json() or {}
if not isinstance(data.get('match', {}), dict):
return api_error('match must be a JSON object', 400)
if not isinstance(data.get("match", {}), dict):
return api_error("match must be a JSON object", 400)
manager = get_alert_manager()
rule_id = manager.add_rule(data)
return api_success(data={'rule_id': rule_id})
return api_success(data={"rule_id": rule_id})
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
@alerts_bp.route("/rules/<int:rule_id>", methods=["PUT", "PATCH"])
def update_rule(rule_id: int):
data = request.get_json() or {}
manager = get_alert_manager()
ok = manager.update_rule(rule_id, data)
if not ok:
return api_error('Rule not found or no changes', 404)
return api_error("Rule not found or no changes", 404)
return api_success()
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
@alerts_bp.route("/rules/<int:rule_id>", methods=["DELETE"])
def delete_rule(rule_id: int):
manager = get_alert_manager()
ok = manager.delete_rule(rule_id)
if not ok:
return api_error('Rule not found', 404)
return api_error("Rule not found", 404)
return api_success()
@alerts_bp.route('/events', methods=['GET'])
@alerts_bp.route("/events", methods=["GET"])
def list_events():
manager = get_alert_manager()
limit = request.args.get('limit', default=100, type=int)
mode = request.args.get('mode')
severity = request.args.get('severity')
limit = request.args.get("limit", default=100, type=int)
mode = request.args.get("mode")
severity = request.args.get("severity")
events = manager.list_events(limit=limit, mode=mode, severity=severity)
return api_success(data={'events': events})
return api_success(data={"events": events})
@alerts_bp.route('/stream', methods=['GET'])
@alerts_bp.route("/stream", methods=["GET"])
def stream_alerts() -> Response:
manager = get_alert_manager()
def generate() -> Generator[str, None, None]:
for event in manager.stream_events(timeout=1.0):
yield format_sse(event)
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
response = Response(
sse_stream_fanout(
source_queue=manager._queue,
channel_key="alerts",
timeout=1.0,
keepalive_interval=30.0,
),
mimetype="text/event-stream",
)
response.headers["Cache-Control"] = "no-cache"
response.headers["X-Accel-Buffering"] = "no"
response.headers["Connection"] = "keep-alive"
return response
+30 -16
View File
@@ -1924,7 +1924,13 @@ def start_aprs() -> Response:
@aprs_bp.route('/stop', methods=['POST'])
def stop_aprs() -> Response:
"""Stop APRS decoder."""
"""Stop APRS decoder.
Releases the SDR device immediately so the status panel updates
without waiting for process termination. Process cleanup runs in a
background thread to avoid blocking the HTTP response (which caused
frontend timeout errors when two processes each took up to 2s to die).
"""
global aprs_active_device, aprs_active_sdr_type
with app_module.aprs_lock:
@@ -1939,6 +1945,28 @@ def stop_aprs() -> Response:
if not processes_to_stop:
return api_error('APRS decoder not running', 400)
# Release SDR device immediately so status panel reflects the
# change without waiting for process termination.
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None
aprs_active_sdr_type = None
# Capture refs to clear before releasing the lock
master_fd = getattr(app_module, 'aprs_master_fd', None)
app_module.aprs_process = None
if hasattr(app_module, 'aprs_rtl_process'):
app_module.aprs_rtl_process = None
app_module.aprs_master_fd = None
# Terminate processes in background so the response returns fast.
# Each proc.wait() can block up to PROCESS_TERMINATE_TIMEOUT (2s),
# which previously caused the frontend 2200ms fetch to abort.
def _cleanup():
# Close PTY master fd first — this unblocks the stream thread
if master_fd is not None:
with contextlib.suppress(OSError):
os.close(master_fd)
for proc in processes_to_stop:
try:
proc.terminate()
@@ -1948,21 +1976,7 @@ def stop_aprs() -> Response:
except Exception as e:
logger.error(f"Error stopping APRS process: {e}")
# Close PTY master fd
if hasattr(app_module, 'aprs_master_fd') and app_module.aprs_master_fd is not None:
with contextlib.suppress(OSError):
os.close(app_module.aprs_master_fd)
app_module.aprs_master_fd = None
app_module.aprs_process = None
if hasattr(app_module, 'aprs_rtl_process'):
app_module.aprs_rtl_process = None
# Release SDR device
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None
aprs_active_sdr_type = None
threading.Thread(target=_cleanup, daemon=True).start()
return jsonify({'status': 'stopped'})
+555 -523
View File
File diff suppressed because it is too large Load Diff
+54 -36
View File
@@ -40,14 +40,16 @@ from utils.trilateration import (
estimate_location_from_observations,
)
logger = logging.getLogger('intercept.controller')
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
# Multi-agent SSE fanout state (per-client queues).
_agent_stream_subscribers: set[queue.Queue] = set()
_agent_stream_subscribers_lock = threading.Lock()
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
logger = logging.getLogger('intercept.controller')
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
AGENT_HEALTH_TIMEOUT_SECONDS = 2.0
AGENT_STATUS_TIMEOUT_SECONDS = 2.5
# Multi-agent SSE fanout state (per-client queues).
_agent_stream_subscribers: set[queue.Queue] = set()
_agent_stream_subscribers_lock = threading.Lock()
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
def _broadcast_agent_data(payload: dict) -> None:
@@ -77,14 +79,18 @@ def get_agents():
agents = list_agents(active_only=active_only)
# Optionally refresh status for each agent
refresh = request.args.get('refresh', 'false').lower() == 'true'
if refresh:
for agent in agents:
try:
client = create_client_from_agent(agent)
agent['healthy'] = client.health_check()
except Exception:
agent['healthy'] = False
refresh = request.args.get('refresh', 'false').lower() == 'true'
if refresh:
for agent in agents:
try:
client = AgentClient(
agent['base_url'],
api_key=agent.get('api_key'),
timeout=AGENT_HEALTH_TIMEOUT_SECONDS,
)
agent['healthy'] = client.health_check()
except Exception:
agent['healthy'] = False
return jsonify({
'status': 'success',
@@ -327,27 +333,36 @@ def check_all_agents_health():
'error': None
}
try:
client = create_client_from_agent(agent)
# Time the health check
start_time = time.time()
is_healthy = client.health_check()
response_time = (time.time() - start_time) * 1000
try:
client = AgentClient(
agent['base_url'],
api_key=agent.get('api_key'),
timeout=AGENT_HEALTH_TIMEOUT_SECONDS,
)
# Time the health check
start_time = time.time()
is_healthy = client.health_check()
response_time = (time.time() - start_time) * 1000
result['healthy'] = is_healthy
result['response_time_ms'] = round(response_time, 1)
if is_healthy:
# Update last_seen in database
update_agent(agent['id'], update_last_seen=True)
# Also fetch running modes
try:
status = client.get_status()
result['running_modes'] = status.get('running_modes', [])
result['running_modes_detail'] = status.get('running_modes_detail', {})
except Exception:
# Update last_seen in database
update_agent(agent['id'], update_last_seen=True)
# Also fetch running modes
try:
status_client = AgentClient(
agent['base_url'],
api_key=agent.get('api_key'),
timeout=AGENT_STATUS_TIMEOUT_SECONDS,
)
status = status_client.get_status()
result['running_modes'] = status.get('running_modes', [])
result['running_modes_detail'] = status.get('running_modes_detail', {})
except Exception:
pass # Status fetch is optional
except AgentConnectionError as e:
@@ -673,6 +688,7 @@ def stream_all_agents():
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
yield format_sse({'type': 'keepalive'})
try:
while True:
@@ -709,11 +725,13 @@ def agent_management_page():
return render_template('agents.html', version=VERSION)
@controller_bp.route('/monitor')
def network_monitor_page():
"""Render the network monitor page for multi-agent aggregated view."""
@controller_bp.route('/monitor')
def network_monitor_page():
"""Render the network monitor page for multi-agent aggregated view."""
from flask import render_template
return render_template('network_monitor.html')
from config import VERSION
return render_template('network_monitor.html', version=VERSION)
# =============================================================================
+238
View File
@@ -0,0 +1,238 @@
"""Drone intelligence routes — multi-vector UAV detection."""
from __future__ import annotations
import logging
import os
import platform
import queue
import subprocess
import threading
from flask import Blueprint, Response, jsonify, request
import app as app_module
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
from utils.drone.correlator import DroneCorrelator
from utils.drone.remote_id import RemoteIDScanner
from utils.drone.rf_detector import RFDetector
from utils.sse import sse_stream_fanout
from utils.validation import validate_device_index
logger = logging.getLogger("intercept.drone")
drone_bp = Blueprint("drone", __name__, url_prefix="/drone")
_correlator: DroneCorrelator | None = None
_remote_id_scanner: RemoteIDScanner | None = None
_rf_detector: RFDetector | None = None
_obs_queue: queue.Queue | None = None # raw observations from scanners/detectors
_relay_thread: threading.Thread | None = None
_drone_running = False
_drone_lock = threading.Lock()
_SENTINEL = object()
def _relay_observations() -> None:
"""Read raw observations from _obs_queue and feed them into the correlator."""
while True:
obs = _obs_queue.get()
if obs is _SENTINEL:
break
if _correlator is not None:
_correlator.process(obs)
def _ensure_workers() -> None:
global _correlator, _remote_id_scanner, _rf_detector, _obs_queue, _relay_thread
if _obs_queue is None:
_obs_queue = queue.Queue(maxsize=512)
if _correlator is None:
_correlator = DroneCorrelator(output_queue=app_module.drone_queue)
if _remote_id_scanner is None:
_remote_id_scanner = RemoteIDScanner(output_queue=_obs_queue)
if _rf_detector is None:
_rf_detector = RFDetector(output_queue=_obs_queue)
if _relay_thread is None or not _relay_thread.is_alive():
_relay_thread = threading.Thread(target=_relay_observations, daemon=True)
_relay_thread.start()
@drone_bp.route("/devices")
def devices():
"""Return available WiFi interfaces and SDR devices for drone detection."""
result: dict = {"wifi_interfaces": [], "sdr_devices": []}
# WiFi interfaces via iw/iwconfig
if platform.system() == "Darwin":
try:
out = subprocess.run(
["networksetup", "-listallhardwareports"],
capture_output=True,
text=True,
timeout=5,
).stdout
lines = out.split("\n")
for i, line in enumerate(lines):
if "Wi-Fi" in line or "AirPort" in line:
port = line.replace("Hardware Port:", "").strip()
for j in range(i + 1, min(i + 3, len(lines))):
if "Device:" in lines[j]:
dev = lines[j].split("Device:")[1].strip()
result["wifi_interfaces"].append(
{
"name": dev,
"display_name": f"{port} ({dev})",
"type": "internal",
"monitor_capable": False,
}
)
break
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
pass
else:
try:
out = subprocess.run(["iw", "dev"], capture_output=True, text=True, timeout=5).stdout
current: str | None = None
for line in out.split("\n"):
line = line.strip()
if line.startswith("Interface"):
current = line.split()[1]
elif current and "type" in line:
iface_type = line.split()[-1]
result["wifi_interfaces"].append(
{
"name": current,
"display_name": f"{current} ({iface_type})",
"type": iface_type,
"monitor_capable": True,
}
)
current = None
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
try:
out = subprocess.run(["iwconfig"], capture_output=True, text=True, timeout=5).stdout
for line in out.split("\n"):
if "IEEE 802.11" in line:
iface = line.split()[0]
result["wifi_interfaces"].append(
{
"name": iface,
"display_name": f"{iface} (managed)",
"type": "managed",
"monitor_capable": True,
}
)
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
pass
# SDR devices
try:
from utils.sdr import SDRFactory
for sdr in SDRFactory.detect_devices():
sdr_type = sdr.sdr_type.value if hasattr(sdr.sdr_type, "value") else str(sdr.sdr_type)
display = sdr.name
if sdr.serial and sdr.serial not in ("N/A", "Unknown"):
display = f"{sdr.name} (SN: {sdr.serial[-8:]})"
result["sdr_devices"].append(
{"index": sdr.index, "name": sdr.name, "display_name": display, "type": sdr_type}
)
except Exception:
pass
running_as_root = os.geteuid() == 0
warnings = []
if not running_as_root:
warnings.append(
{
"type": "privileges",
"message": "Not running as root — WiFi monitor mode may be unavailable.",
}
)
return jsonify(
{
"status": "ok",
"devices": result,
"running_as_root": running_as_root,
"warnings": warnings,
}
)
@drone_bp.route("/status")
def status():
vectors = []
if _remote_id_scanner and _remote_id_scanner.running:
vectors.append("REMOTE_ID")
if _rf_detector and _rf_detector.running:
vectors.append("RF")
return jsonify(
{
"running": _drone_running,
"vectors": vectors,
"contact_count": len(_correlator.get_all()) if _correlator else 0,
}
)
@drone_bp.route("/contacts")
def contacts():
if not _correlator:
return jsonify([])
return jsonify(_correlator.get_all())
@drone_bp.route("/start", methods=["POST"])
def start():
global _drone_running
body = request.json or {}
wifi_iface = body.get("wifi_iface") or None
try:
rtl_index = validate_device_index(body.get("rtl_sdr_index", 0))
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
use_hackrf = bool(body.get("use_hackrf", True))
with _drone_lock:
_ensure_workers()
if not _drone_running:
if _remote_id_scanner:
_remote_id_scanner.start(wifi_iface=wifi_iface)
if _rf_detector:
_rf_detector.start(rtl_sdr_index=rtl_index, use_hackrf=use_hackrf)
_drone_running = True
logger.info("Drone detection started")
return jsonify({"status": "ok", "running": True})
@drone_bp.route("/stop", methods=["POST"])
def stop():
global _drone_running
with _drone_lock:
if _remote_id_scanner:
_remote_id_scanner.stop()
if _rf_detector:
_rf_detector.stop()
if _obs_queue is not None:
_obs_queue.put_nowait(_SENTINEL)
_drone_running = False
logger.info("Drone detection stopped")
return jsonify({"status": "ok", "running": False})
@drone_bp.route("/stream")
def stream():
return Response(
sse_stream_fanout(
source_queue=app_module.drone_queue,
channel_key="drone",
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
+567
View File
@@ -0,0 +1,567 @@
"""Ground Station REST API + SSE + WebSocket endpoints.
Phases implemented here:
1 Profile CRUD, scheduler control, observation history, SSE stream
3 SigMF recording browser (list / download / delete)
5 /ws/satellite_waterfall WebSocket
6 Rotator config / status / point / park endpoints
"""
from __future__ import annotations
import json
import queue
from pathlib import Path
from flask import Blueprint, Response, jsonify, request, send_file
from utils.logging import get_logger
from utils.sse import sse_stream_fanout
logger = get_logger('intercept.ground_station.routes')
ground_station_bp = Blueprint('ground_station', __name__, url_prefix='/ground_station')
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_scheduler():
from utils.ground_station.scheduler import get_ground_station_scheduler
return get_ground_station_scheduler()
def _get_queue():
import app as _app
return getattr(_app, 'ground_station_queue', None) or queue.Queue()
# ---------------------------------------------------------------------------
# Phase 1 — Observation Profiles
# ---------------------------------------------------------------------------
@ground_station_bp.route('/profiles', methods=['GET'])
def list_profiles():
from utils.ground_station.observation_profile import list_profiles as _list
return jsonify([p.to_dict() for p in _list()])
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['GET'])
def get_profile(norad_id: int):
from utils.ground_station.observation_profile import get_profile as _get
p = _get(norad_id)
if not p:
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
return jsonify(p.to_dict())
@ground_station_bp.route('/profiles', methods=['POST'])
def create_profile():
data = request.get_json(force=True) or {}
try:
_validate_profile(data)
except ValueError as e:
return jsonify({'error': str(e)}), 400
from utils.ground_station.observation_profile import (
ObservationProfile,
legacy_decoder_to_tasks,
normalize_tasks,
save_profile,
tasks_to_legacy_decoder,
)
tasks = normalize_tasks(data.get('tasks'))
if not tasks:
tasks = legacy_decoder_to_tasks(
str(data.get('decoder_type', 'fm')),
bool(data.get('record_iq', False)),
)
profile = ObservationProfile(
norad_id=int(data['norad_id']),
name=str(data['name']),
frequency_mhz=float(data['frequency_mhz']),
decoder_type=tasks_to_legacy_decoder(tasks),
gain=float(data.get('gain', 40.0)),
bandwidth_hz=int(data.get('bandwidth_hz', 200_000)),
min_elevation=float(data.get('min_elevation', 10.0)),
enabled=bool(data.get('enabled', True)),
record_iq=bool(data.get('record_iq', False)) or ('record_iq' in tasks),
iq_sample_rate=int(data.get('iq_sample_rate', 2_400_000)),
tasks=tasks,
)
saved = save_profile(profile)
return jsonify(saved.to_dict()), 201
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['PUT'])
def update_profile(norad_id: int):
data = request.get_json(force=True) or {}
from utils.ground_station.observation_profile import (
get_profile as _get,
)
from utils.ground_station.observation_profile import (
legacy_decoder_to_tasks,
normalize_tasks,
save_profile,
tasks_to_legacy_decoder,
)
existing = _get(norad_id)
if not existing:
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
# Apply updates
for field, cast in [
('name', str), ('frequency_mhz', float), ('decoder_type', str),
('gain', float), ('bandwidth_hz', int), ('min_elevation', float),
]:
if field in data:
setattr(existing, field, cast(data[field]))
for field in ('enabled', 'record_iq'):
if field in data:
setattr(existing, field, bool(data[field]))
if 'iq_sample_rate' in data:
existing.iq_sample_rate = int(data['iq_sample_rate'])
if 'tasks' in data:
existing.tasks = normalize_tasks(data['tasks'])
elif 'decoder_type' in data:
existing.tasks = legacy_decoder_to_tasks(
str(data.get('decoder_type', existing.decoder_type)),
bool(data.get('record_iq', existing.record_iq)),
)
existing.decoder_type = tasks_to_legacy_decoder(existing.tasks)
existing.record_iq = bool(existing.record_iq) or ('record_iq' in existing.tasks)
saved = save_profile(existing)
return jsonify(saved.to_dict())
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['DELETE'])
def delete_profile(norad_id: int):
from utils.ground_station.observation_profile import delete_profile as _del
ok = _del(norad_id)
if not ok:
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
return jsonify({'status': 'deleted', 'norad_id': norad_id})
# ---------------------------------------------------------------------------
# Phase 1 — Scheduler control
# ---------------------------------------------------------------------------
@ground_station_bp.route('/scheduler/status', methods=['GET'])
def scheduler_status():
return jsonify(_get_scheduler().get_status())
@ground_station_bp.route('/scheduler/enable', methods=['POST'])
def scheduler_enable():
data = request.get_json(force=True) or {}
try:
lat = float(data.get('lat', 0.0))
lon = float(data.get('lon', 0.0))
device = int(data.get('device', 0))
sdr_type = str(data.get('sdr_type', 'rtlsdr'))
except (TypeError, ValueError) as e:
return jsonify({'error': str(e)}), 400
status = _get_scheduler().enable(lat=lat, lon=lon, device=device, sdr_type=sdr_type)
return jsonify(status)
@ground_station_bp.route('/scheduler/disable', methods=['POST'])
def scheduler_disable():
return jsonify(_get_scheduler().disable())
@ground_station_bp.route('/scheduler/observations', methods=['GET'])
def get_observations():
return jsonify(_get_scheduler().get_scheduled_observations())
@ground_station_bp.route('/scheduler/trigger/<int:norad_id>', methods=['POST'])
def trigger_manual(norad_id: int):
ok, msg = _get_scheduler().trigger_manual(norad_id)
if not ok:
return jsonify({'error': msg}), 400
return jsonify({'status': 'started', 'message': msg})
@ground_station_bp.route('/scheduler/stop', methods=['POST'])
def stop_active():
return jsonify(_get_scheduler().stop_active())
# ---------------------------------------------------------------------------
# Phase 1 — Observation history (from DB)
# ---------------------------------------------------------------------------
@ground_station_bp.route('/observations', methods=['GET'])
def observation_history():
limit = min(int(request.args.get('limit', 50)), 200)
try:
from utils.database import get_db
with get_db() as conn:
rows = conn.execute(
'''SELECT * FROM ground_station_observations
ORDER BY created_at DESC LIMIT ?''',
(limit,),
).fetchall()
return jsonify([dict(r) for r in rows])
except Exception as e:
logger.error(f"Failed to fetch observation history: {e}")
return jsonify([])
# ---------------------------------------------------------------------------
# Phase 1 — SSE stream
# ---------------------------------------------------------------------------
@ground_station_bp.route('/stream')
def sse_stream():
gs_queue = _get_queue()
return Response(
sse_stream_fanout(gs_queue, 'ground_station'),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
},
)
# ---------------------------------------------------------------------------
# Phase 3 — SigMF recording browser
# ---------------------------------------------------------------------------
@ground_station_bp.route('/recordings', methods=['GET'])
def list_recordings():
try:
from utils.database import get_db
with get_db() as conn:
rows = conn.execute(
'SELECT * FROM sigmf_recordings ORDER BY created_at DESC LIMIT 100'
).fetchall()
return jsonify([dict(r) for r in rows])
except Exception as e:
logger.error(f"Failed to fetch recordings: {e}")
return jsonify([])
@ground_station_bp.route('/recordings/<int:rec_id>', methods=['GET'])
def get_recording(rec_id: int):
try:
from utils.database import get_db
with get_db() as conn:
row = conn.execute(
'SELECT * FROM sigmf_recordings WHERE id=?', (rec_id,)
).fetchone()
if not row:
return jsonify({'error': 'Not found'}), 404
return jsonify(dict(row))
except Exception as e:
return jsonify({'error': str(e)}), 500
@ground_station_bp.route('/recordings/<int:rec_id>', methods=['DELETE'])
def delete_recording(rec_id: int):
try:
from utils.database import get_db
with get_db() as conn:
row = conn.execute(
'SELECT sigmf_data_path, sigmf_meta_path FROM sigmf_recordings WHERE id=?',
(rec_id,),
).fetchone()
if not row:
return jsonify({'error': 'Not found'}), 404
# Remove files
for path_col in ('sigmf_data_path', 'sigmf_meta_path'):
p = Path(row[path_col])
if p.exists():
p.unlink(missing_ok=True)
conn.execute('DELETE FROM sigmf_recordings WHERE id=?', (rec_id,))
return jsonify({'status': 'deleted', 'id': rec_id})
except Exception as e:
return jsonify({'error': str(e)}), 500
@ground_station_bp.route('/recordings/<int:rec_id>/download/<file_type>')
def download_recording(rec_id: int, file_type: str):
if file_type not in ('data', 'meta'):
return jsonify({'error': 'file_type must be data or meta'}), 400
try:
from utils.database import get_db
with get_db() as conn:
row = conn.execute(
'SELECT sigmf_data_path, sigmf_meta_path FROM sigmf_recordings WHERE id=?',
(rec_id,),
).fetchone()
if not row:
return jsonify({'error': 'Not found'}), 404
col = 'sigmf_data_path' if file_type == 'data' else 'sigmf_meta_path'
p = Path(row[col])
if not p.exists():
return jsonify({'error': 'File not found on disk'}), 404
mimetype = 'application/octet-stream' if file_type == 'data' else 'application/json'
return send_file(p, mimetype=mimetype, as_attachment=True, download_name=p.name)
except Exception as e:
return jsonify({'error': str(e)}), 500
@ground_station_bp.route('/outputs', methods=['GET'])
def list_outputs():
try:
query = '''
SELECT * FROM ground_station_outputs
WHERE (? IS NULL OR norad_id = ?)
AND (? IS NULL OR observation_id = ?)
AND (? IS NULL OR output_type = ?)
ORDER BY created_at DESC
LIMIT 200
'''
norad_id = request.args.get('norad_id', type=int)
observation_id = request.args.get('observation_id', type=int)
output_type = request.args.get('type')
from utils.database import get_db
with get_db() as conn:
rows = conn.execute(
query,
(
norad_id, norad_id,
observation_id, observation_id,
output_type, output_type,
),
).fetchall()
results = []
for row in rows:
item = dict(row)
metadata_raw = item.get('metadata_json')
if metadata_raw:
try:
item['metadata'] = json.loads(metadata_raw)
except json.JSONDecodeError:
item['metadata'] = {}
else:
item['metadata'] = {}
item.pop('metadata_json', None)
results.append(item)
return jsonify(results)
except Exception as e:
return jsonify({'error': str(e)}), 500
@ground_station_bp.route('/outputs/<int:output_id>/download', methods=['GET'])
def download_output(output_id: int):
try:
from utils.database import get_db
with get_db() as conn:
row = conn.execute(
'SELECT file_path FROM ground_station_outputs WHERE id=?',
(output_id,),
).fetchone()
if not row:
return jsonify({'error': 'Not found'}), 404
p = Path(row['file_path'])
if not p.exists():
return jsonify({'error': 'File not found on disk'}), 404
return send_file(p, as_attachment=True, download_name=p.name)
except Exception as e:
return jsonify({'error': str(e)}), 500
@ground_station_bp.route('/decode-jobs', methods=['GET'])
def list_decode_jobs():
try:
query = '''
SELECT * FROM ground_station_decode_jobs
WHERE (? IS NULL OR norad_id = ?)
AND (? IS NULL OR observation_id = ?)
AND (? IS NULL OR backend = ?)
ORDER BY created_at DESC
LIMIT ?
'''
norad_id = request.args.get('norad_id', type=int)
observation_id = request.args.get('observation_id', type=int)
backend = request.args.get('backend')
limit = min(request.args.get('limit', 20, type=int) or 20, 200)
from utils.database import get_db
with get_db() as conn:
rows = conn.execute(
query,
(
norad_id, norad_id,
observation_id, observation_id,
backend, backend,
limit,
),
).fetchall()
results = []
for row in rows:
item = dict(row)
details_raw = item.get('details_json')
if details_raw:
try:
item['details'] = json.loads(details_raw)
except json.JSONDecodeError:
item['details'] = {}
else:
item['details'] = {}
item.pop('details_json', None)
results.append(item)
return jsonify(results)
except Exception as e:
return jsonify({'error': str(e)}), 500
# ---------------------------------------------------------------------------
# Phase 5 — Live waterfall WebSocket
# ---------------------------------------------------------------------------
def init_ground_station_websocket(app) -> None:
"""Register the /ws/satellite_waterfall WebSocket endpoint."""
try:
from flask_sock import Sock
except ImportError:
logger.warning("flask-sock not installed — satellite waterfall WebSocket disabled")
return
sock = Sock(app)
@sock.route('/ws/satellite_waterfall')
def satellite_waterfall_ws(ws):
"""Stream binary waterfall frames from the active ground station IQ bus."""
scheduler = _get_scheduler()
wf_queue = scheduler.waterfall_queue
from utils.sse import subscribe_fanout_queue
sub_queue, unsubscribe = subscribe_fanout_queue(
source_queue=wf_queue,
channel_key='gs_waterfall',
subscriber_queue_size=120,
)
try:
while True:
try:
frame = sub_queue.get(timeout=1.0)
try:
ws.send(frame)
except Exception:
break
except queue.Empty:
if not ws.connected:
break
finally:
unsubscribe()
# ---------------------------------------------------------------------------
# Phase 6 — Rotator
# ---------------------------------------------------------------------------
@ground_station_bp.route('/rotator/status', methods=['GET'])
def rotator_status():
from utils.rotator import get_rotator
return jsonify(get_rotator().get_status())
@ground_station_bp.route('/rotator/config', methods=['POST'])
def rotator_config():
data = request.get_json(force=True) or {}
host = str(data.get('host', '127.0.0.1'))
port = int(data.get('port', 4533))
from utils.rotator import get_rotator
ok = get_rotator().connect(host, port)
if not ok:
return jsonify({'error': f'Could not connect to rotctld at {host}:{port}'}), 503
return jsonify(get_rotator().get_status())
@ground_station_bp.route('/rotator/point', methods=['POST'])
def rotator_point():
data = request.get_json(force=True) or {}
try:
az = float(data['az'])
el = float(data['el'])
except (KeyError, TypeError, ValueError) as e:
return jsonify({'error': f'az and el required: {e}'}), 400
from utils.rotator import get_rotator
ok = get_rotator().point_to(az, el)
if not ok:
return jsonify({'error': 'Rotator command failed'}), 503
return jsonify({'status': 'ok', 'az': az, 'el': el})
@ground_station_bp.route('/rotator/park', methods=['POST'])
def rotator_park():
from utils.rotator import get_rotator
ok = get_rotator().park()
if not ok:
return jsonify({'error': 'Rotator park failed'}), 503
return jsonify({'status': 'parked'})
@ground_station_bp.route('/rotator/disconnect', methods=['POST'])
def rotator_disconnect():
from utils.rotator import get_rotator
get_rotator().disconnect()
return jsonify({'status': 'disconnected'})
# ---------------------------------------------------------------------------
# Input validation
# ---------------------------------------------------------------------------
def _validate_profile(data: dict) -> None:
if 'norad_id' not in data:
raise ValueError("norad_id is required")
if 'name' not in data:
raise ValueError("name is required")
if 'frequency_mhz' not in data:
raise ValueError("frequency_mhz is required")
try:
norad_id = int(data['norad_id'])
if norad_id <= 0:
raise ValueError("norad_id must be positive")
except (TypeError, ValueError):
raise ValueError("norad_id must be a positive integer")
try:
freq = float(data['frequency_mhz'])
if not (0.1 <= freq <= 3000.0):
raise ValueError("frequency_mhz must be between 0.1 and 3000")
except (TypeError, ValueError):
raise ValueError("frequency_mhz must be a number between 0.1 and 3000")
from utils.ground_station.observation_profile import VALID_TASK_TYPES
valid_decoders = {'fm', 'afsk', 'gmsk', 'bpsk', 'iq_only'}
if 'tasks' in data:
if not isinstance(data['tasks'], list):
raise ValueError("tasks must be a list")
invalid = [
str(task) for task in data['tasks']
if str(task).strip().lower() not in VALID_TASK_TYPES
]
if invalid:
raise ValueError(
f"tasks contains unsupported values: {', '.join(invalid)}"
)
else:
dt = str(data.get('decoder_type', 'fm'))
if dt not in valid_decoders:
raise ValueError(f"decoder_type must be one of: {', '.join(sorted(valid_decoders))}")
+213
View File
@@ -0,0 +1,213 @@
"""Meshcore device routes.
Endpoints for connecting to Meshcore devices (serial, TCP, BLE),
streaming live events, and managing messages, contacts, and nodes.
"""
from __future__ import annotations
import json
import queue
from flask import Blueprint, Response, jsonify, request
from utils.logging import get_logger
from utils.meshcore import (
BLEConfig,
MeshcoreContact,
SerialConfig,
TCPConfig,
get_meshcore_client,
is_meshcore_available,
list_serial_ports,
)
from utils.responses import api_error
logger = get_logger("intercept.meshcore")
meshcore_bp = Blueprint("meshcore", __name__, url_prefix="/meshcore")
def _client():
return get_meshcore_client()
# ---------------------------------------------------------------------------
# Status & connection management
# ---------------------------------------------------------------------------
@meshcore_bp.route("/status")
def status():
if not is_meshcore_available():
return jsonify(
{
"available": False,
"state": "unavailable",
"message": "meshcore package not installed. Run: pip install meshcore",
}
)
c = _client()
state, message = c.get_state()
payload = {"available": True, "state": state.value}
if message:
payload["message"] = message
return jsonify(payload)
@meshcore_bp.route("/connect", methods=["POST"])
def connect():
if not is_meshcore_available():
return api_error("meshcore not installed", 503)
data = request.get_json(silent=True) or {}
transport = data.get("transport", "serial")
if transport == "serial":
config = SerialConfig(port=data.get("port"), baud=int(data.get("baud", 115200)))
elif transport == "tcp":
host = data.get("host", "localhost")
port = int(data.get("port", 5000))
config = TCPConfig(host=host, port=port)
elif transport == "ble":
config = BLEConfig(device_address=data.get("address"))
else:
return api_error(f"Unknown transport: {transport}", 400)
_client().connect(config)
return jsonify({"status": "connecting", "transport": transport})
@meshcore_bp.route("/disconnect", methods=["POST"])
def disconnect():
_client().disconnect()
return jsonify({"status": "disconnected"})
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
@meshcore_bp.route("/ports")
def ports():
return jsonify({"ports": list_serial_ports()})
@meshcore_bp.route("/ble/scan")
def ble_scan():
if not is_meshcore_available():
return api_error("meshcore not installed", 503)
devices = _client().scan_ble()
return jsonify({"devices": devices})
# ---------------------------------------------------------------------------
# SSE stream
# ---------------------------------------------------------------------------
@meshcore_bp.route("/stream")
def stream():
def _gen():
q = _client().get_queue()
while True:
try:
event = q.get(timeout=30)
yield f"data: {json.dumps(event)}\n\n"
except queue.Empty:
yield ": keepalive\n\n"
return Response(
_gen(),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ---------------------------------------------------------------------------
# Messages
# ---------------------------------------------------------------------------
@meshcore_bp.route("/messages")
def messages():
return jsonify({"messages": _client().get_messages()})
@meshcore_bp.route("/send", methods=["POST"])
def send():
data = request.get_json(silent=True) or {}
text = data.get("text", "").strip()
recipient_id = data.get("recipient_id", "BROADCAST")
if not text:
return api_error("text is required", 400)
if len(text) > 237:
return api_error("text exceeds 237-character Meshcore limit", 400)
_client().send_text(recipient_id, text)
return jsonify({"status": "queued"})
# ---------------------------------------------------------------------------
# Nodes
# ---------------------------------------------------------------------------
@meshcore_bp.route("/nodes")
def nodes():
return jsonify({"nodes": _client().get_nodes()})
@meshcore_bp.route("/repeaters")
def repeaters():
return jsonify({"repeaters": _client().get_repeaters()})
# ---------------------------------------------------------------------------
# Contacts
# ---------------------------------------------------------------------------
@meshcore_bp.route("/contacts", methods=["GET"])
def list_contacts():
return jsonify({"contacts": _client().get_contacts()})
@meshcore_bp.route("/contacts", methods=["POST"])
def add_contact():
data = request.get_json(silent=True) or {}
node_id = data.get("node_id", "").strip()
name = data.get("name", "").strip()
public_key = data.get("public_key", "").strip()
if not node_id or not name or not public_key:
return api_error("node_id, name, and public_key are required", 400)
contact = MeshcoreContact(node_id=node_id, name=name, public_key=public_key, last_msg=None)
_client().add_contact(contact)
return jsonify({"status": "added", "contact": contact.to_dict()})
@meshcore_bp.route("/contacts/<node_id>", methods=["DELETE"])
def delete_contact(node_id: str):
removed = _client().remove_contact(node_id)
if not removed:
return api_error("contact not found", 404)
return jsonify({"status": "removed"})
# ---------------------------------------------------------------------------
# Telemetry & traceroute
# ---------------------------------------------------------------------------
@meshcore_bp.route("/telemetry/<node_id>")
def telemetry(node_id: str):
return jsonify({"node_id": node_id, "telemetry": _client().get_telemetry(node_id)})
@meshcore_bp.route("/traceroute", methods=["POST"])
def traceroute():
data = request.get_json(silent=True) or {}
node_id = data.get("node_id", "").strip()
if not node_id:
return api_error("node_id is required", 400)
_client().request_traceroute(node_id)
return jsonify({"status": "requested", "node_id": node_id})
+53 -60
View File
@@ -9,49 +9,43 @@ from flask import Blueprint, request
from utils.database import get_setting, set_setting
from utils.responses import api_error, api_success
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
offline_bp = Blueprint("offline", __name__, url_prefix="/offline")
# Default offline settings
OFFLINE_DEFAULTS = {
'offline.enabled': False,
"offline.enabled": False,
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
'offline.assets_source': 'local',
'offline.fonts_source': 'local',
'offline.tile_provider': 'cartodb_dark_cyan',
'offline.tile_server_url': ''
"offline.assets_source": "local",
"offline.fonts_source": "local",
"offline.tile_provider": "cartodb_dark_cyan",
"offline.tile_server_url": "",
"offline.stadia_key": "",
}
# Asset paths to check
ASSET_PATHS = {
'leaflet': [
'static/vendor/leaflet/leaflet.js',
'static/vendor/leaflet/leaflet.css'
"leaflet": ["static/vendor/leaflet/leaflet.js", "static/vendor/leaflet/leaflet.css"],
"chartjs": ["static/vendor/chartjs/chart.umd.min.js"],
"inter": [
"static/vendor/fonts/Inter-Regular.woff2",
"static/vendor/fonts/Inter-Medium.woff2",
"static/vendor/fonts/Inter-SemiBold.woff2",
"static/vendor/fonts/Inter-Bold.woff2",
],
'chartjs': [
'static/vendor/chartjs/chart.umd.min.js'
"jetbrains": [
"static/vendor/fonts/JetBrainsMono-Regular.woff2",
"static/vendor/fonts/JetBrainsMono-Medium.woff2",
"static/vendor/fonts/JetBrainsMono-SemiBold.woff2",
"static/vendor/fonts/JetBrainsMono-Bold.woff2",
],
'inter': [
'static/vendor/fonts/Inter-Regular.woff2',
'static/vendor/fonts/Inter-Medium.woff2',
'static/vendor/fonts/Inter-SemiBold.woff2',
'static/vendor/fonts/Inter-Bold.woff2'
"leaflet_images": [
"static/vendor/leaflet/images/marker-icon.png",
"static/vendor/leaflet/images/marker-icon-2x.png",
"static/vendor/leaflet/images/marker-shadow.png",
"static/vendor/leaflet/images/layers.png",
"static/vendor/leaflet/images/layers-2x.png",
],
'jetbrains': [
'static/vendor/fonts/JetBrainsMono-Regular.woff2',
'static/vendor/fonts/JetBrainsMono-Medium.woff2',
'static/vendor/fonts/JetBrainsMono-SemiBold.woff2',
'static/vendor/fonts/JetBrainsMono-Bold.woff2'
],
'leaflet_images': [
'static/vendor/leaflet/images/marker-icon.png',
'static/vendor/leaflet/images/marker-icon-2x.png',
'static/vendor/leaflet/images/marker-shadow.png',
'static/vendor/leaflet/images/layers.png',
'static/vendor/leaflet/images/layers-2x.png'
],
'leaflet_heat': [
'static/vendor/leaflet-heat/leaflet-heat.js'
]
"leaflet_heat": ["static/vendor/leaflet-heat/leaflet-heat.js"],
}
@@ -63,26 +57,26 @@ def get_offline_settings():
return settings
@offline_bp.route('/settings', methods=['GET'])
@offline_bp.route("/settings", methods=["GET"])
def get_settings():
"""Get current offline settings."""
settings = get_offline_settings()
return api_success(data={'settings': settings})
return api_success(data={"settings": settings})
@offline_bp.route('/settings', methods=['POST'])
@offline_bp.route("/settings", methods=["POST"])
def save_setting():
"""Save an offline setting."""
data = request.get_json()
if not data or 'key' not in data or 'value' not in data:
return api_error('Missing key or value', 400)
if not data or "key" not in data or "value" not in data:
return api_error("Missing key or value", 400)
key = data['key']
value = data['value']
key = data["key"]
value = data["value"]
# Validate key is an allowed setting
if key not in OFFLINE_DEFAULTS:
return api_error(f'Unknown setting: {key}', 400)
return api_error(f"Unknown setting: {key}", 400)
# Validate value type matches default
default_type = type(OFFLINE_DEFAULTS[key])
@@ -90,18 +84,18 @@ def save_setting():
# Try to convert
try:
if default_type == bool:
value = str(value).lower() in ('true', '1', 'yes')
value = str(value).lower() in ("true", "1", "yes")
else:
value = default_type(value)
except (ValueError, TypeError):
return api_error(f'Invalid value type for {key}', 400)
return api_error(f"Invalid value type for {key}", 400)
set_setting(key, value)
return api_success(data={'key': key, 'value': value})
return api_success(data={"key": key, "value": value})
@offline_bp.route('/status', methods=['GET'])
@offline_bp.route("/status", methods=["GET"])
def get_status():
"""Check status of local assets."""
# Get the app root directory
@@ -119,37 +113,36 @@ def get_status():
available = False
missing.append(path)
results[asset_name] = {
'available': available,
'missing': missing if not available else []
}
results[asset_name] = {"available": available, "missing": missing if not available else []}
if not available:
all_available = False
return api_success(data={
'all_available': all_available,
'assets': results,
'offline_enabled': get_setting('offline.enabled', False)
})
return api_success(
data={
"all_available": all_available,
"assets": results,
"offline_enabled": get_setting("offline.enabled", False),
}
)
@offline_bp.route('/check-asset', methods=['GET'])
@offline_bp.route("/check-asset", methods=["GET"])
def check_asset():
"""Check if a specific asset file exists."""
path = request.args.get('path', '')
path = request.args.get("path", "")
if not path:
return api_error('Missing path parameter', 400)
return api_error("Missing path parameter", 400)
# Security: only allow checking within static/vendor
if not path.startswith('/static/vendor/'):
return api_error('Invalid path', 400)
if not path.startswith("/static/vendor/"):
return api_error("Invalid path", 400)
# Remove leading slash and construct full path
relative_path = path.lstrip('/')
relative_path = path.lstrip("/")
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
full_path = os.path.join(app_root, relative_path)
exists = os.path.exists(full_path)
return api_success(data={'path': path, 'exists': exists})
return api_success(data={"path": path, "exists": exists})
+169 -54
View File
@@ -7,15 +7,16 @@ telemetry (position, altitude, temperature, humidity, pressure) on the
from __future__ import annotations
import contextlib
import json
import os
import queue
import shutil
import socket
import subprocess
import sys
import threading
import contextlib
import json
import os
import queue
import shlex
import shutil
import socket
import subprocess
import sys
import threading
import time
from typing import Any
@@ -42,9 +43,10 @@ from utils.validation import (
validate_longitude,
)
logger = get_logger('intercept.radiosonde')
radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde')
logger = get_logger('intercept.radiosonde')
radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde')
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Track radiosonde state
radiosonde_running = False
@@ -66,8 +68,8 @@ AUTO_RX_PATHS = [
]
def find_auto_rx() -> str | None:
"""Find radiosonde_auto_rx script/binary."""
def find_auto_rx() -> str | None:
"""Find radiosonde_auto_rx script/binary."""
# Check PATH first
path = shutil.which('radiosonde_auto_rx')
if path:
@@ -77,10 +79,123 @@ def find_auto_rx() -> str | None:
if os.path.isfile(p) and os.access(p, os.X_OK):
return p
# Check for Python script (not executable but runnable)
for p in AUTO_RX_PATHS:
if os.path.isfile(p):
return p
return None
for p in AUTO_RX_PATHS:
if os.path.isfile(p):
return p
return None
def _resolve_shebang_interpreter(script_path: str) -> str | None:
"""Resolve a Python interpreter from a script shebang if possible."""
try:
with open(script_path, encoding='utf-8', errors='ignore') as handle:
first_line = handle.readline().strip()
except OSError:
return None
if not first_line.startswith('#!'):
return None
parts = shlex.split(first_line[2:].strip())
if not parts:
return None
if os.path.basename(parts[0]) == 'env' and len(parts) > 1:
return shutil.which(parts[1])
return parts[0]
def _resolve_pip_python(pip_bin: str | None) -> str | None:
"""Resolve the Python interpreter used by a pip executable."""
if not pip_bin:
return None
return _resolve_shebang_interpreter(pip_bin)
def _build_auto_rx_env(auto_rx_dir: str) -> dict[str, str]:
"""Build environment for radiosonde_auto_rx with compatibility shims."""
env = os.environ.copy()
python_path_entries = [PROJECT_ROOT, auto_rx_dir]
existing_pythonpath = env.get('PYTHONPATH', '')
if existing_pythonpath:
python_path_entries.append(existing_pythonpath)
env['PYTHONPATH'] = os.pathsep.join(entry for entry in python_path_entries if entry)
return env
def _iter_auto_rx_python_candidates(auto_rx_path: str):
"""Yield plausible Python interpreters for radiosonde_auto_rx."""
auto_rx_abs = os.path.abspath(auto_rx_path)
auto_rx_dir = os.path.dirname(auto_rx_abs)
install_root = os.path.dirname(auto_rx_dir)
install_parent = os.path.dirname(install_root)
candidates = [
_resolve_shebang_interpreter(auto_rx_abs),
sys.executable,
os.path.join(install_root, 'venv', 'bin', 'python'),
os.path.join(install_root, 'venv', 'bin', 'python3'),
os.path.join(install_root, '.venv', 'bin', 'python'),
os.path.join(install_root, '.venv', 'bin', 'python3'),
os.path.join(auto_rx_dir, 'venv', 'bin', 'python'),
os.path.join(auto_rx_dir, 'venv', 'bin', 'python3'),
os.path.join(auto_rx_dir, '.venv', 'bin', 'python'),
os.path.join(auto_rx_dir, '.venv', 'bin', 'python3'),
os.path.join(install_parent, 'venv', 'bin', 'python'),
os.path.join(install_parent, 'venv', 'bin', 'python3'),
os.path.join(install_parent, '.venv', 'bin', 'python'),
os.path.join(install_parent, '.venv', 'bin', 'python3'),
_resolve_pip_python(shutil.which('pip3')),
_resolve_pip_python(shutil.which('pip')),
shutil.which('python3'),
shutil.which('python'),
'/usr/local/bin/python3',
'/usr/local/bin/python',
'/usr/bin/python3',
]
seen: set[str] = set()
for candidate in candidates:
if not candidate:
continue
candidate_abs = os.path.abspath(candidate)
if candidate_abs in seen:
continue
seen.add(candidate_abs)
if os.path.isfile(candidate_abs) and os.access(candidate_abs, os.X_OK):
yield candidate_abs
def _resolve_auto_rx_python(auto_rx_path: str) -> tuple[str | None, str, list[str]]:
"""Pick a Python interpreter that can import autorx.scan successfully."""
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
auto_rx_env = _build_auto_rx_env(auto_rx_dir)
checked: list[str] = []
last_error = 'No usable Python interpreter found'
for python_bin in _iter_auto_rx_python_candidates(auto_rx_path):
checked.append(python_bin)
try:
dep_check = subprocess.run(
[python_bin, '-c', 'import autorx.scan'],
cwd=auto_rx_dir,
env=auto_rx_env,
capture_output=True,
timeout=10,
)
except Exception as exc:
last_error = str(exc)
continue
if dep_check.returncode == 0:
return python_bin, '', checked
stderr_output = dep_check.stderr.decode('utf-8', errors='ignore').strip()
stdout_output = dep_check.stdout.decode('utf-8', errors='ignore').strip()
last_error = stderr_output or stdout_output or f'Interpreter exited with code {dep_check.returncode}'
return None, last_error, checked
def generate_station_cfg(
@@ -544,43 +659,43 @@ def start_radiosonde():
logger.error(f"Failed to generate radiosonde config: {e}")
return api_error(str(e), 500)
# Build command - auto_rx -c expects the path to station.cfg
cfg_abs = os.path.abspath(cfg_path)
if auto_rx_path.endswith('.py'):
cmd = [sys.executable, auto_rx_path, '-c', cfg_abs]
else:
cmd = [auto_rx_path, '-c', cfg_abs]
# Set cwd to the auto_rx directory so 'from autorx.scan import ...' works
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
# Quick dependency check before launching the full process
if auto_rx_path.endswith('.py'):
dep_check = subprocess.run(
[sys.executable, '-c', 'import autorx.scan'],
cwd=auto_rx_dir,
capture_output=True,
timeout=10,
)
if dep_check.returncode != 0:
dep_error = dep_check.stderr.decode('utf-8', errors='ignore').strip()
logger.error(f"radiosonde_auto_rx dependency check failed:\n{dep_error}")
app_module.release_sdr_device(device_int, sdr_type_str)
return api_error(
'radiosonde_auto_rx dependencies not satisfied. '
f'Re-run setup.sh to install. Error: {dep_error[:500]}',
500,
)
try:
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
app_module.radiosonde_process = subprocess.Popen(
cmd,
# Build command - auto_rx -c expects the path to station.cfg
cfg_abs = os.path.abspath(cfg_path)
if auto_rx_path.endswith('.py'):
selected_python, dep_error, checked_interpreters = _resolve_auto_rx_python(auto_rx_path)
if not selected_python:
logger.error(
"radiosonde_auto_rx dependency check failed across interpreters %s: %s",
checked_interpreters,
dep_error,
)
app_module.release_sdr_device(device_int, sdr_type_str)
checked_msg = ', '.join(checked_interpreters) if checked_interpreters else 'none'
return api_error(
'radiosonde_auto_rx dependencies not satisfied. '
'Install or repair its Python environment (missing packages such as semver). '
f'Checked interpreters: {checked_msg}. '
f'Last error: {dep_error[:500]}',
500,
)
cmd = [selected_python, auto_rx_path, '-c', cfg_abs]
else:
cmd = [auto_rx_path, '-c', cfg_abs]
# Set cwd to the auto_rx directory so 'from autorx.scan import ...' works
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
auto_rx_env = _build_auto_rx_env(auto_rx_dir)
try:
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
app_module.radiosonde_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
start_new_session=True,
cwd=auto_rx_dir,
)
stderr=subprocess.PIPE,
start_new_session=True,
cwd=auto_rx_dir,
env=auto_rx_env,
)
# Wait briefly for process to start
time.sleep(2.0)
+679 -304
View File
File diff suppressed because it is too large Load Diff
+122 -19
View File
@@ -1,26 +1,101 @@
"""Settings management routes."""
from __future__ import annotations
import os
import subprocess
import sys
from flask import Blueprint, Response, jsonify, request
from utils.database import (
from __future__ import annotations
import contextlib
import os
import re
import subprocess
import sys
import threading
from pathlib import Path
from flask import Blueprint, Response, jsonify, request
from utils.database import (
delete_setting,
get_all_settings,
get_correlations,
get_setting,
set_setting,
)
from utils.logging import get_logger
from utils.responses import api_error, api_success
logger = get_logger('intercept.settings')
settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
)
from utils.logging import get_logger
from utils.responses import api_error, api_success
from utils.validation import validate_latitude, validate_longitude
logger = get_logger('intercept.settings')
settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
_env_lock = threading.Lock()
def _get_env_file_path() -> Path:
"""Return the project .env path."""
return Path(__file__).resolve().parent.parent / '.env'
def _write_env_value(key: str, value: str, env_path: Path | None = None) -> None:
"""Create or update a single key in the project .env file."""
path = env_path or _get_env_file_path()
path.parent.mkdir(parents=True, exist_ok=True)
with _env_lock:
lines = path.read_text().splitlines() if path.exists() else [
'# INTERCEPT environment configuration',
'',
]
pattern = re.compile(rf'^\s*{re.escape(key)}=')
updated = False
new_lines: list[str] = []
for line in lines:
if pattern.match(line):
if not updated:
new_lines.append(f'{key}={value}')
updated = True
continue
new_lines.append(line)
if not updated:
if new_lines and new_lines[-1] != '':
new_lines.append('')
new_lines.append(f'{key}={value}')
path.write_text('\n'.join(new_lines).rstrip('\n') + '\n')
sudo_uid = os.environ.get('INTERCEPT_SUDO_UID')
sudo_gid = os.environ.get('INTERCEPT_SUDO_GID')
if os.geteuid() == 0 and sudo_uid and sudo_gid:
with contextlib.suppress(OSError, ValueError):
os.chown(path, int(sudo_uid), int(sudo_gid))
def _apply_runtime_observer_defaults(lat: float, lon: float) -> None:
"""Update in-process defaults so refreshed pages use the saved location."""
lat_str = str(lat)
lon_str = str(lon)
os.environ['INTERCEPT_DEFAULT_LAT'] = lat_str
os.environ['INTERCEPT_DEFAULT_LON'] = lon_str
import config
config.DEFAULT_LATITUDE = lat
config.DEFAULT_LONGITUDE = lon
with contextlib.suppress(Exception):
import app as app_module
app_module.DEFAULT_LATITUDE = lat
app_module.DEFAULT_LONGITUDE = lon
with contextlib.suppress(Exception):
from routes import adsb as adsb_routes
adsb_routes.DEFAULT_LATITUDE = lat
adsb_routes.DEFAULT_LONGITUDE = lon
with contextlib.suppress(Exception):
from routes import ais as ais_routes
ais_routes.DEFAULT_LATITUDE = lat
ais_routes.DEFAULT_LONGITUDE = lon
@settings_bp.route('', methods=['GET'])
@@ -92,8 +167,8 @@ def update_single_setting(key: str) -> Response:
return api_error(str(e), 500)
@settings_bp.route('/<key>', methods=['DELETE'])
def delete_single_setting(key: str) -> Response:
@settings_bp.route('/<key>', methods=['DELETE'])
def delete_single_setting(key: str) -> Response:
"""Delete a setting."""
try:
deleted = delete_setting(key)
@@ -106,7 +181,35 @@ def delete_single_setting(key: str) -> Response:
}), 404
except Exception as e:
logger.error(f"Error deleting setting {key}: {e}")
return api_error(str(e), 500)
return api_error(str(e), 500)
@settings_bp.route('/observer-location', methods=['POST'])
def save_observer_location() -> Response:
"""Persist observer location to .env and refresh in-process defaults."""
data = request.json or {}
try:
lat = validate_latitude(data.get('lat'))
lon = validate_longitude(data.get('lon'))
except ValueError as exc:
return api_error(str(exc), 400)
try:
_write_env_value('INTERCEPT_DEFAULT_LAT', str(lat))
_write_env_value('INTERCEPT_DEFAULT_LON', str(lon))
_apply_runtime_observer_defaults(lat, lon)
return api_success(
data={
'lat': lat,
'lon': lon,
'saved': ['INTERCEPT_DEFAULT_LAT', 'INTERCEPT_DEFAULT_LON'],
},
message='Observer location saved to .env',
)
except Exception as exc:
logger.error(f'Error saving observer location to .env: {exc}')
return api_error(str(exc), 500)
# =============================================================================
+228 -224
View File
@@ -16,6 +16,7 @@ from typing import Any
from flask import Blueprint, Response, jsonify, request, send_file
import app as app_module
from routes.satellite import get_cached_tle
from utils.event_pipeline import process_event
from utils.logging import get_logger
from utils.responses import api_error
@@ -26,13 +27,13 @@ from utils.sstv import (
is_sstv_available,
)
logger = get_logger('intercept.sstv')
logger = get_logger("intercept.sstv")
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
sstv_bp = Blueprint("sstv", __name__, url_prefix="/sstv")
# ISS SSTV runs on a fixed downlink; allow a small entry tolerance so users
# can type nearby values and still land on the canonical center frequency.
ISS_SSTV_MODULATION = 'fm'
ISS_SSTV_MODULATION = "fm"
ISS_SSTV_FREQUENCIES = (ISS_SSTV_FREQ,)
ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
@@ -59,7 +60,7 @@ _timescale_lock = threading.Lock()
# Track which device is being used
sstv_active_device: int | None = None
sstv_active_sdr_type: str = 'rtlsdr'
sstv_active_sdr_type: str = "rtlsdr"
def _progress_callback(data: dict) -> None:
@@ -82,7 +83,7 @@ def _normalize_iss_frequency(frequency_mhz: float) -> float | None:
return None
@sstv_bp.route('/status')
@sstv_bp.route("/status")
def get_status():
"""
Get SSTV decoder status.
@@ -94,24 +95,24 @@ def get_status():
decoder = get_sstv_decoder()
result = {
'available': available,
'decoder': decoder.decoder_available,
'running': decoder.is_running,
'iss_frequency': ISS_SSTV_FREQ,
'modulation': ISS_SSTV_MODULATION,
'image_count': len(decoder.get_images()),
'doppler_enabled': decoder.doppler_enabled,
"available": available,
"decoder": decoder.decoder_available,
"running": decoder.is_running,
"iss_frequency": ISS_SSTV_FREQ,
"modulation": ISS_SSTV_MODULATION,
"image_count": len(decoder.get_images()),
"doppler_enabled": decoder.doppler_enabled,
}
# Include Doppler info if available
doppler_info = decoder.last_doppler_info
if doppler_info:
result['doppler'] = doppler_info.to_dict()
result["doppler"] = doppler_info.to_dict()
return jsonify(result)
@sstv_bp.route('/start', methods=['POST'])
@sstv_bp.route("/start", methods=["POST"])
def start_decoder():
"""
Start SSTV decoder.
@@ -133,20 +134,24 @@ def start_decoder():
JSON with start status.
"""
if not is_sstv_available():
return jsonify({
'status': 'error',
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow'
}), 400
return jsonify(
{
"status": "error",
"message": "SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow",
}
), 400
decoder = get_sstv_decoder()
if decoder.is_running:
return jsonify({
'status': 'already_running',
'frequency': ISS_SSTV_FREQ,
'modulation': ISS_SSTV_MODULATION,
'doppler_enabled': decoder.doppler_enabled
})
return jsonify(
{
"status": "already_running",
"frequency": ISS_SSTV_FREQ,
"modulation": ISS_SSTV_MODULATION,
"doppler_enabled": decoder.doppler_enabled,
}
)
# Clear queue
while not _sstv_queue.empty():
@@ -157,43 +162,38 @@ def start_decoder():
# Get parameters
data = request.get_json(silent=True) or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
sdr_type_str = data.get("sdr_type", "rtlsdr")
if sdr_type_str != 'rtlsdr':
return jsonify({
'status': 'error',
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
}), 400
if sdr_type_str != "rtlsdr":
return jsonify(
{
"status": "error",
"message": f"{sdr_type_str.replace('_', ' ').title()} is not yet supported for this mode. Please use an RTL-SDR device.",
}
), 400
frequency = data.get('frequency', ISS_SSTV_FREQ)
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower()
device_index = data.get('device', 0)
latitude = data.get('latitude')
longitude = data.get('longitude')
frequency = data.get("frequency", ISS_SSTV_FREQ)
modulation = str(data.get("modulation", ISS_SSTV_MODULATION)).strip().lower()
device_index = data.get("device", 0)
latitude = data.get("latitude")
longitude = data.get("longitude")
# Validate modulation (ISS mode is FM-only)
if modulation != ISS_SSTV_MODULATION:
return jsonify({
'status': 'error',
'message': f'Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode'
}), 400
return jsonify(
{"status": "error", "message": f"Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode"}
), 400
# Validate frequency
try:
frequency = float(frequency)
normalized_frequency = _normalize_iss_frequency(frequency)
if normalized_frequency is None:
supported = ', '.join(f'{freq:.3f}' for freq in ISS_SSTV_FREQUENCIES)
return jsonify({
'status': 'error',
'message': f'Supported ISS SSTV frequency: {supported} MHz FM'
}), 400
supported = ", ".join(f"{freq:.3f}" for freq in ISS_SSTV_FREQUENCIES)
return jsonify({"status": "error", "message": f"Supported ISS SSTV frequency: {supported} MHz FM"}), 400
frequency = normalized_frequency
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid frequency'
}), 400
return jsonify({"status": "error", "message": "Invalid frequency"}), 400
# Validate location if provided
if latitude is not None and longitude is not None:
@@ -201,20 +201,11 @@ def start_decoder():
latitude = float(latitude)
longitude = float(longitude)
if not (-90 <= latitude <= 90):
return jsonify({
'status': 'error',
'message': 'Latitude must be between -90 and 90'
}), 400
return jsonify({"status": "error", "message": "Latitude must be between -90 and 90"}), 400
if not (-180 <= longitude <= 180):
return jsonify({
'status': 'error',
'message': 'Longitude must be between -180 and 180'
}), 400
return jsonify({"status": "error", "message": "Longitude must be between -180 and 180"}), 400
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid latitude or longitude'
}), 400
return jsonify({"status": "error", "message": "Invalid latitude or longitude"}), 400
else:
latitude = None
longitude = None
@@ -222,13 +213,9 @@ def start_decoder():
# Claim SDR device
global sstv_active_device, sstv_active_sdr_type
device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'sstv', sdr_type_str)
error = app_module.claim_sdr_device(device_int, "sstv", sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
return jsonify({"status": "error", "error_type": "DEVICE_BUSY", "message": error}), 409
# Set callback and start
decoder.set_callback(_progress_callback)
@@ -245,28 +232,25 @@ def start_decoder():
sstv_active_sdr_type = sdr_type_str
result = {
'status': 'started',
'frequency': frequency,
'modulation': ISS_SSTV_MODULATION,
'device': device_index,
'doppler_enabled': decoder.doppler_enabled
"status": "started",
"frequency": frequency,
"modulation": ISS_SSTV_MODULATION,
"device": device_index,
"doppler_enabled": decoder.doppler_enabled,
}
# Include initial Doppler info if available
if decoder.doppler_enabled and decoder.last_doppler_info:
result['doppler'] = decoder.last_doppler_info.to_dict()
result["doppler"] = decoder.last_doppler_info.to_dict()
return jsonify(result)
else:
# Release device on failure
app_module.release_sdr_device(device_int, sdr_type_str)
return jsonify({
'status': 'error',
'message': 'Failed to start decoder'
}), 500
return jsonify({"status": "error", "message": "Failed to start decoder"}), 500
@sstv_bp.route('/stop', methods=['POST'])
@sstv_bp.route("/stop", methods=["POST"])
def stop_decoder():
"""
Stop SSTV decoder.
@@ -283,10 +267,10 @@ def stop_decoder():
app_module.release_sdr_device(sstv_active_device, sstv_active_sdr_type)
sstv_active_device = None
return jsonify({'status': 'stopped'})
return jsonify({"status": "stopped"})
@sstv_bp.route('/doppler')
@sstv_bp.route("/doppler")
def get_doppler():
"""
Get current Doppler shift information.
@@ -299,27 +283,28 @@ def get_doppler():
decoder = get_sstv_decoder()
if not decoder.doppler_enabled:
return jsonify({
'status': 'disabled',
'message': 'Doppler tracking not enabled. Provide latitude/longitude when starting decoder.'
})
return jsonify(
{
"status": "disabled",
"message": "Doppler tracking not enabled. Provide latitude/longitude when starting decoder.",
}
)
doppler_info = decoder.last_doppler_info
if not doppler_info:
return jsonify({
'status': 'unavailable',
'message': 'Doppler data not yet available'
})
return jsonify({"status": "unavailable", "message": "Doppler data not yet available"})
return jsonify({
'status': 'ok',
'doppler': doppler_info.to_dict(),
'nominal_frequency_mhz': ISS_SSTV_FREQ,
'corrected_frequency_mhz': doppler_info.frequency_hz / 1_000_000
})
return jsonify(
{
"status": "ok",
"doppler": doppler_info.to_dict(),
"nominal_frequency_mhz": ISS_SSTV_FREQ,
"corrected_frequency_mhz": doppler_info.frequency_hz / 1_000_000,
}
)
@sstv_bp.route('/images')
@sstv_bp.route("/images")
def list_images():
"""
Get list of decoded SSTV images.
@@ -333,18 +318,14 @@ def list_images():
decoder = get_sstv_decoder()
images = decoder.get_images()
limit = request.args.get('limit', type=int)
limit = request.args.get("limit", type=int)
if limit and limit > 0:
images = images[-limit:]
return jsonify({
'status': 'ok',
'images': [img.to_dict() for img in images],
'count': len(images)
})
return jsonify({"status": "ok", "images": [img.to_dict() for img in images], "count": len(images)})
@sstv_bp.route('/images/<filename>')
@sstv_bp.route("/images/<filename>")
def get_image(filename: str):
"""
Get a decoded SSTV image file.
@@ -358,22 +339,22 @@ def get_image(filename: str):
decoder = get_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return api_error('Invalid filename', 400)
if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum():
return api_error("Invalid filename", 400)
if not filename.endswith('.png'):
return api_error('Only PNG files supported', 400)
if not filename.endswith(".png"):
return api_error("Only PNG files supported", 400)
# Find image in decoder's output directory
image_path = decoder._output_dir / filename
if not image_path.exists():
return api_error('Image not found', 404)
return api_error("Image not found", 404)
return send_file(image_path, mimetype='image/png')
return send_file(image_path, mimetype="image/png")
@sstv_bp.route('/images/<filename>/download')
@sstv_bp.route("/images/<filename>/download")
def download_image(filename: str):
"""
Download a decoded SSTV image file.
@@ -387,21 +368,21 @@ def download_image(filename: str):
decoder = get_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return api_error('Invalid filename', 400)
if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum():
return api_error("Invalid filename", 400)
if not filename.endswith('.png'):
return api_error('Only PNG files supported', 400)
if not filename.endswith(".png"):
return api_error("Only PNG files supported", 400)
image_path = decoder._output_dir / filename
if not image_path.exists():
return api_error('Image not found', 404)
return api_error("Image not found", 404)
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
return send_file(image_path, mimetype="image/png", as_attachment=True, download_name=filename)
@sstv_bp.route('/images/<filename>', methods=['DELETE'])
@sstv_bp.route("/images/<filename>", methods=["DELETE"])
def delete_image(filename: str):
"""
Delete a decoded SSTV image.
@@ -415,19 +396,19 @@ def delete_image(filename: str):
decoder = get_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return api_error('Invalid filename', 400)
if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum():
return api_error("Invalid filename", 400)
if not filename.endswith('.png'):
return api_error('Only PNG files supported', 400)
if not filename.endswith(".png"):
return api_error("Only PNG files supported", 400)
if decoder.delete_image(filename):
return jsonify({'status': 'ok'})
return jsonify({"status": "ok"})
else:
return api_error('Image not found', 404)
return api_error("Image not found", 404)
@sstv_bp.route('/images', methods=['DELETE'])
@sstv_bp.route("/images", methods=["DELETE"])
def delete_all_images():
"""
Delete all decoded SSTV images.
@@ -437,10 +418,10 @@ def delete_all_images():
"""
decoder = get_sstv_decoder()
count = decoder.delete_all_images()
return jsonify({'status': 'ok', 'deleted': count})
return jsonify({"status": "ok", "deleted": count})
@sstv_bp.route('/stream')
@sstv_bp.route("/stream")
def stream_progress():
"""
SSE stream of SSTV decode progress.
@@ -453,22 +434,23 @@ def stream_progress():
Returns:
SSE stream (text/event-stream)
"""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('sstv', msg, msg.get('type'))
process_event("sstv", msg, msg.get("type"))
response = Response(
sse_stream_fanout(
source_queue=_sstv_queue,
channel_key='sstv',
channel_key="sstv",
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
mimetype="text/event-stream",
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
response.headers["Cache-Control"] = "no-cache"
response.headers["X-Accel-Buffering"] = "no"
response.headers["Connection"] = "keep-alive"
return response
@@ -478,11 +460,12 @@ def _get_timescale():
with _timescale_lock:
if _timescale is None:
from skyfield.api import load
_timescale = load.timescale()
_timescale = load.timescale(builtin=True)
return _timescale
@sstv_bp.route('/iss-schedule')
@sstv_bp.route("/iss-schedule")
def iss_schedule():
"""
Get ISS pass schedule for SSTV reception.
@@ -500,24 +483,23 @@ def iss_schedule():
"""
global _iss_schedule_cache, _iss_schedule_cache_time, _iss_schedule_cache_key
lat = request.args.get('latitude', type=float)
lon = request.args.get('longitude', type=float)
hours = request.args.get('hours', 48, type=int)
lat = request.args.get("latitude", type=float)
lon = request.args.get("longitude", type=float)
hours = request.args.get("hours", 48, type=int)
if lat is None or lon is None:
return jsonify({
'status': 'error',
'message': 'latitude and longitude parameters required'
}), 400
return jsonify({"status": "error", "message": "latitude and longitude parameters required"}), 400
# Cache key: rounded lat/lon (1 decimal place) so nearby locations share cache
cache_key = f"{round(lat, 1)}:{round(lon, 1)}:{hours}"
with _iss_schedule_lock:
now = time.time()
if (_iss_schedule_cache is not None
and cache_key == _iss_schedule_cache_key
and (now - _iss_schedule_cache_time) < ISS_SCHEDULE_CACHE_TTL):
if (
_iss_schedule_cache is not None
and cache_key == _iss_schedule_cache_key
and (now - _iss_schedule_cache_time) < ISS_SCHEDULE_CACHE_TTL
):
return jsonify(_iss_schedule_cache)
try:
@@ -526,15 +508,10 @@ def iss_schedule():
from skyfield.almanac import find_discrete
from skyfield.api import EarthSatellite, wgs84
from data.satellites import TLE_SATELLITES
# Get ISS TLE
iss_tle = TLE_SATELLITES.get('ISS')
# Get ISS TLE from live cache (kept fresh by auto-refresh)
iss_tle = get_cached_tle("ISS")
if not iss_tle:
return jsonify({
'status': 'error',
'message': 'ISS TLE data not available'
}), 500
return jsonify({"status": "error", "message": "ISS TLE data not available"}), 500
ts = _get_timescale()
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
@@ -549,7 +526,7 @@ def iss_schedule():
alt, _, _ = topocentric.altaz()
return alt.degrees > 0
above_horizon.step_days = 1/720
above_horizon.step_days = 1 / 720
times, events = find_discrete(t0, t1, above_horizon)
@@ -588,23 +565,25 @@ def iss_schedule():
max_el = alt.degrees
if max_el >= 10: # Min elevation filter
passes.append({
'satellite': 'ISS',
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
'startTimeISO': rise_time.utc_datetime().isoformat(),
'maxEl': round(max_el, 1),
'duration': duration_minutes,
'color': '#00ffff'
})
passes.append(
{
"satellite": "ISS",
"startTime": rise_time.utc_datetime().strftime("%Y-%m-%d %H:%M UTC"),
"startTimeISO": rise_time.utc_datetime().isoformat(),
"maxEl": round(max_el, 1),
"duration": duration_minutes,
"color": "#00ffff",
}
)
i += 1
result = {
'status': 'ok',
'passes': passes,
'count': len(passes),
'sstv_frequency': ISS_SSTV_FREQ,
'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.'
"status": "ok",
"passes": passes,
"count": len(passes),
"sstv_frequency": ISS_SSTV_FREQ,
"note": "ISS SSTV events are not continuous. Check ARISS.org for scheduled events.",
}
# Update cache
@@ -616,17 +595,11 @@ def iss_schedule():
return jsonify(result)
except ImportError:
return jsonify({
'status': 'error',
'message': 'skyfield library not installed'
}), 503
return jsonify({"status": "error", "message": "skyfield library not installed"}), 503
except Exception as e:
logger.error(f"Error getting ISS schedule: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
return jsonify({"status": "error", "message": str(e)}), 500
def _fetch_iss_position() -> dict | None:
@@ -644,14 +617,14 @@ def _fetch_iss_position() -> dict | None:
# Try primary API: Where The ISS At
try:
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=3)
response = requests.get("https://api.wheretheiss.at/v1/satellites/25544", timeout=3)
if response.status_code == 200:
data = response.json()
cached = {
'lat': float(data['latitude']),
'lon': float(data['longitude']),
'altitude': float(data.get('altitude', 420)),
'source': 'wheretheiss',
"lat": float(data["latitude"]),
"lon": float(data["longitude"]),
"altitude": float(data.get("altitude", 420)),
"source": "wheretheiss",
}
except Exception as e:
logger.warning(f"Where The ISS At API failed: {e}")
@@ -659,15 +632,15 @@ def _fetch_iss_position() -> dict | None:
# Try fallback API: Open Notify
if cached is None:
try:
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=3)
response = requests.get("http://api.open-notify.org/iss-now.json", timeout=3)
if response.status_code == 200:
data = response.json()
if data.get('message') == 'success':
if data.get("message") == "success":
cached = {
'lat': float(data['iss_position']['latitude']),
'lon': float(data['iss_position']['longitude']),
'altitude': 420,
'source': 'open-notify',
"lat": float(data["iss_position"]["latitude"]),
"lon": float(data["iss_position"]["longitude"]),
"altitude": 420,
"source": "open-notify",
}
except Exception as e:
logger.warning(f"Open Notify API failed: {e}")
@@ -680,7 +653,7 @@ def _fetch_iss_position() -> dict | None:
return cached
@sstv_bp.route('/iss-position')
@sstv_bp.route("/iss-position")
def iss_position():
"""
Get current ISS position from real-time API.
@@ -698,28 +671,25 @@ def iss_position():
"""
from datetime import datetime
observer_lat = request.args.get('latitude', type=float)
observer_lon = request.args.get('longitude', type=float)
observer_lat = request.args.get("latitude", type=float)
observer_lon = request.args.get("longitude", type=float)
pos = _fetch_iss_position()
if pos is None:
return jsonify({
'status': 'error',
'message': 'Unable to fetch ISS position from real-time APIs'
}), 503
return jsonify({"status": "error", "message": "Unable to fetch ISS position from real-time APIs"}), 503
result = {
'status': 'ok',
'lat': pos['lat'],
'lon': pos['lon'],
'altitude': pos['altitude'],
'timestamp': datetime.utcnow().isoformat(),
'source': pos['source'],
"status": "ok",
"lat": pos["lat"],
"lon": pos["lon"],
"altitude": pos["altitude"],
"timestamp": datetime.utcnow().isoformat(),
"source": pos["source"],
}
# Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None:
result.update(_calculate_observer_data(pos['lat'], pos['lon'], observer_lat, observer_lon))
result.update(_calculate_observer_data(pos["lat"], pos["lon"], observer_lat, observer_lon))
return jsonify(result)
@@ -743,7 +713,7 @@ def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs
# Haversine for ground distance
dlat = lat2 - lat1
dlon = lon2 - lon1
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
c = 2 * math.asin(math.sqrt(a))
ground_distance = earth_radius * c
@@ -763,14 +733,60 @@ def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs
azimuth = math.degrees(math.atan2(y, x))
azimuth = (azimuth + 360) % 360
return {
'elevation': round(elevation, 1),
'azimuth': round(azimuth, 1),
'distance': round(slant_range, 1)
}
return {"elevation": round(elevation, 1), "azimuth": round(azimuth, 1), "distance": round(slant_range, 1)}
@sstv_bp.route('/decode-file', methods=['POST'])
@sstv_bp.route("/iss-track")
def iss_track():
"""
Return ISS ground track points propagated from TLE data.
Uses skyfield SGP4 propagation over ±90 minutes (roughly one full orbit)
to produce an accurate track that accounts for Earth's rotation.
Returns:
JSON with list of {lat, lon, past} points.
"""
try:
from datetime import timedelta
from skyfield.api import EarthSatellite, wgs84
iss_tle = get_cached_tle("ISS")
if not iss_tle:
return jsonify({"status": "error", "message": "ISS TLE not available"}), 500
ts = _get_timescale()
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
now = ts.now()
now_dt = now.utc_datetime()
track = []
for minutes_offset in range(-90, 91, 1):
t_point = ts.utc(now_dt + timedelta(minutes=minutes_offset))
try:
geo = satellite.at(t_point)
sp = wgs84.subpoint(geo)
track.append(
{
"lat": round(float(sp.latitude.degrees), 4),
"lon": round(float(sp.longitude.degrees), 4),
"past": minutes_offset < 0,
}
)
except Exception:
continue
return jsonify({"status": "ok", "track": track})
except ImportError:
return jsonify({"status": "error", "message": "skyfield not installed"}), 503
except Exception as e:
logger.error(f"Error computing ISS track: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@sstv_bp.route("/decode-file", methods=["POST"])
def decode_file():
"""
Decode SSTV from an uploaded audio file.
@@ -780,23 +796,18 @@ def decode_file():
Returns:
JSON with decoded images.
"""
if 'audio' not in request.files:
return jsonify({
'status': 'error',
'message': 'No audio file provided'
}), 400
if "audio" not in request.files:
return jsonify({"status": "error", "message": "No audio file provided"}), 400
audio_file = request.files['audio']
audio_file = request.files["audio"]
if not audio_file.filename:
return jsonify({
'status': 'error',
'message': 'No file selected'
}), 400
return jsonify({"status": "error", "message": "No file selected"}), 400
# Save to temp file
import tempfile
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
audio_file.save(tmp.name)
tmp_path = tmp.name
@@ -804,18 +815,11 @@ def decode_file():
decoder = get_sstv_decoder()
images = decoder.decode_file(tmp_path)
return jsonify({
'status': 'ok',
'images': [img.to_dict() for img in images],
'count': len(images)
})
return jsonify({"status": "ok", "images": [img.to_dict() for img in images], "count": len(images)})
except Exception as e:
logger.error(f"Error decoding file: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
return jsonify({"status": "error", "message": str(e)}), 500
finally:
# Clean up temp file
+5 -3
View File
@@ -490,6 +490,7 @@ def _start_sweep_internal(
bt_interface: str = '',
sdr_device: int | None = None,
verbose_results: bool = False,
custom_ranges: list[dict] | None = None,
) -> dict:
"""Start a TSCM sweep without request context."""
global _sweep_running, _sweep_thread, _current_sweep_id
@@ -532,7 +533,7 @@ def _start_sweep_internal(
_sweep_thread = threading.Thread(
target=_run_sweep,
args=(sweep_type, baseline_id, wifi_enabled, bt_enabled, rf_enabled,
wifi_interface, bt_interface, sdr_device, verbose_results),
wifi_interface, bt_interface, sdr_device, verbose_results, custom_ranges),
daemon=True
)
_sweep_thread.start()
@@ -1127,7 +1128,8 @@ def _run_sweep(
wifi_interface: str = '',
bt_interface: str = '',
sdr_device: int | None = None,
verbose_results: bool = False
verbose_results: bool = False,
custom_ranges: list[dict] | None = None,
) -> None:
"""
Run the TSCM sweep in a background thread.
@@ -1504,7 +1506,7 @@ def _run_sweep(
'rf_count': len(all_rf),
})
# Try RF scan even if sdr_device is None (will use device 0)
rf_signals = _scan_rf_signals(sdr_device, sweep_ranges=preset.get('ranges'))
rf_signals = _scan_rf_signals(sdr_device, sweep_ranges=custom_ranges or preset.get('ranges'))
# If no signals and this is first RF scan, send info event
if not rf_signals and last_rf_scan == 0:
+30 -9
View File
@@ -19,12 +19,9 @@ from flask import Response, jsonify, request
from data.tscm_frequencies import get_all_sweep_presets, get_sweep_preset
from routes.tscm import (
_baseline_recorder,
_current_sweep_id,
_emit_event,
_start_sweep_internal,
_sweep_running,
tscm_bp,
tscm_queue,
)
from utils.database import get_tscm_sweep, update_tscm_sweep
from utils.event_pipeline import process_event
@@ -36,7 +33,8 @@ logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/status')
def tscm_status():
"""Check if any TSCM operation is currently running."""
return jsonify({'running': _sweep_running})
import routes.tscm as _tscm_pkg
return jsonify({'running': _tscm_pkg._sweep_running})
@tscm_bp.route('/sweep/start', methods=['POST'])
@@ -57,6 +55,25 @@ def start_sweep():
bt_interface = data.get('bt_interface', '')
sdr_device = data.get('sdr_device')
# Validate custom frequency ranges if provided
custom_ranges = None
if sweep_type == 'custom':
raw_ranges = data.get('custom_ranges') or []
validated = []
for rng in raw_ranges:
try:
start = float(rng.get('start', 0))
end = float(rng.get('end', 0))
step = float(rng.get('step', 0.1))
if 0 < start < end <= 6000:
validated.append({'start': start, 'end': end, 'step': step,
'name': rng.get('name') or f'{start:.0f}{end:.0f} MHz'})
except (TypeError, ValueError):
pass
if not validated:
return jsonify({'status': 'error', 'message': 'custom sweep requires valid start/end MHz'}), 400
custom_ranges = validated
result = _start_sweep_internal(
sweep_type=sweep_type,
baseline_id=baseline_id,
@@ -67,6 +84,7 @@ def start_sweep():
bt_interface=bt_interface,
sdr_device=sdr_device,
verbose_results=verbose_results,
custom_ranges=custom_ranges,
)
http_status = result.pop('http_status', 200)
return jsonify(result), http_status
@@ -95,14 +113,15 @@ def stop_sweep():
@tscm_bp.route('/sweep/status')
def sweep_status():
"""Get current sweep status."""
import routes.tscm as _tscm_pkg
status = {
'running': _sweep_running,
'sweep_id': _current_sweep_id,
'running': _tscm_pkg._sweep_running,
'sweep_id': _tscm_pkg._current_sweep_id,
}
if _current_sweep_id:
sweep = get_tscm_sweep(_current_sweep_id)
if _tscm_pkg._current_sweep_id:
sweep = get_tscm_sweep(_tscm_pkg._current_sweep_id)
if sweep:
status['sweep'] = sweep
@@ -113,12 +132,14 @@ def sweep_status():
def sweep_stream():
"""SSE stream for real-time sweep updates."""
import routes.tscm as _tscm_pkg
def _on_msg(msg: dict[str, Any]) -> None:
process_event('tscm', msg, msg.get('type'))
return Response(
sse_stream_fanout(
source_queue=tscm_queue,
source_queue=_tscm_pkg.tscm_queue,
channel_key='tscm',
timeout=1.0,
keepalive_interval=30.0,
+27 -3
View File
@@ -83,11 +83,35 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
data['type'] = 'vdl2'
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
# Enrich with translated ACARS label at top level (consistent with ACARS route)
# Flatten nested VDL2 identifying fields to top level for correlator matching
# dumpvdl2 nests flight/reg inside vdl2.avlc.acars and ICAO in avlc.src.addr
try:
vdl2_inner = data.get('vdl2', data)
acars_payload = (vdl2_inner.get('avlc') or {}).get('acars')
if acars_payload and acars_payload.get('label'):
avlc = vdl2_inner.get('avlc') or {}
acars_payload = avlc.get('acars') or {}
# Promote AVLC source address — this is the aircraft ICAO hex
# Do this FIRST so even non-ACARS VDL2 frames can be correlated
src = avlc.get('src') or {}
src_addr = src.get('addr', '')
src_type = src.get('type', '')
if src_addr and src_type == 'Aircraft':
data['icao'] = src_addr.upper()
data['addr'] = src_addr.upper()
# Promote ACARS fields to top level so FlightCorrelator can match them
if acars_payload.get('flight'):
data['flight'] = acars_payload['flight']
if acars_payload.get('reg'):
data['reg'] = acars_payload['reg']
data['tail'] = acars_payload['reg']
if acars_payload.get('label'):
data['label'] = acars_payload['label']
if acars_payload.get('msg_text'):
data['text'] = acars_payload['msg_text']
# Enrich with translated ACARS label (consistent with ACARS route)
if acars_payload.get('label'):
translation = translate_message({
'label': acars_payload.get('label'),
'text': acars_payload.get('msg_text', ''),
+125 -15
View File
@@ -1,12 +1,15 @@
"""Weather Satellite decoder routes.
Provides endpoints for capturing and decoding weather satellite images
from NOAA (APT) and Meteor (LRPT) satellites using SatDump.
Provides endpoints for capturing and decoding Meteor LRPT weather
imagery, including shared results produced by the ground-station
observation pipeline.
"""
from __future__ import annotations
import json
import queue
from pathlib import Path
from flask import Blueprint, Response, jsonify, request, send_file
@@ -37,6 +40,15 @@ weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
# Queue for SSE progress streaming
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
METEOR_NORAD_IDS = {
'METEOR-M2-3': 57166,
'METEOR-M2-4': 59051,
}
ALLOWED_TEST_DECODE_DIRS = (
Path(__file__).resolve().parent.parent / 'data',
Path(__file__).resolve().parent.parent / 'instance' / 'ground_station' / 'recordings',
)
def _progress_callback(progress: CaptureProgress) -> None:
"""Callback to queue progress updates for SSE stream."""
@@ -120,9 +132,9 @@ def start_capture():
JSON body:
{
"satellite": "NOAA-18", // Required: satellite key
"satellite": "METEOR-M2-3", // Required: satellite key
"device": 0, // RTL-SDR device index (default: 0)
"gain": 40.0, // SDR gain in dB (default: 40)
"gain": 30.0, // SDR gain in dB (default: 30)
"bias_t": false // Enable bias-T for LNA (default: false)
}
@@ -164,7 +176,7 @@ def start_capture():
# Validate device index and gain
try:
device_index = validate_device_index(data.get('device', 0))
gain = validate_gain(data.get('gain', 40.0))
gain = validate_gain(data.get('gain', 30.0))
except ValueError as e:
logger.warning('Invalid parameter in start_capture: %s', e)
return jsonify({
@@ -248,7 +260,7 @@ def test_decode():
JSON body:
{
"satellite": "NOAA-18", // Required: satellite key
"satellite": "METEOR-M2-3", // Required: satellite key
"input_file": "/path/to/file", // Required: server-side file path
"sample_rate": 1000000 // Sample rate in Hz (default: 1000000)
}
@@ -292,14 +304,13 @@ def test_decode():
from pathlib import Path
input_path = Path(input_file)
# Security: restrict to data directory (anchored to app root, not CWD)
allowed_base = Path(__file__).resolve().parent.parent / 'data'
# Restrict test-decode to application-owned sample and recording paths.
try:
resolved = input_path.resolve()
if not resolved.is_relative_to(allowed_base):
if not any(resolved.is_relative_to(base) for base in ALLOWED_TEST_DECODE_DIRS):
return jsonify({
'status': 'error',
'message': 'input_file must be under the data/ directory'
'message': 'input_file must be under INTERCEPT data or ground-station recordings'
}), 403
except (OSError, ValueError):
return jsonify({
@@ -389,21 +400,34 @@ def list_images():
JSON with list of decoded images.
"""
decoder = get_weather_sat_decoder()
images = decoder.get_images()
images = [
{
**img.to_dict(),
'source': 'weather_sat',
'deletable': True,
}
for img in decoder.get_images()
]
images.extend(_get_ground_station_images())
# Filter by satellite if specified
satellite_filter = request.args.get('satellite')
if satellite_filter:
images = [img for img in images if img.satellite == satellite_filter]
images = [
img for img in images
if str(img.get('satellite', '')).upper() == satellite_filter.upper()
]
images.sort(key=lambda img: img.get('timestamp') or '', reverse=True)
# Apply limit
limit = request.args.get('limit', type=int)
if limit and limit > 0:
images = images[-limit:]
images = images[:limit]
return jsonify({
'status': 'ok',
'images': [img.to_dict() for img in images],
'images': images,
'count': len(images),
})
@@ -436,6 +460,36 @@ def get_image(filename: str):
return send_file(image_path, mimetype=mimetype)
@weather_sat_bp.route('/images/shared/<int:output_id>')
def get_shared_image(output_id: int):
"""Serve a Meteor image stored in ground-station outputs."""
try:
from utils.database import get_db
with get_db() as conn:
row = conn.execute(
'''
SELECT file_path FROM ground_station_outputs
WHERE id=? AND output_type='image'
''',
(output_id,),
).fetchone()
except Exception as e:
logger.warning("Failed to load shared weather image %s: %s", output_id, e)
return api_error('Image not found', 404)
if not row:
return api_error('Image not found', 404)
image_path = Path(row['file_path'])
if not image_path.exists():
return api_error('Image not found', 404)
suffix = image_path.suffix.lower()
mimetype = 'image/png' if suffix == '.png' else 'image/jpeg'
return send_file(image_path, mimetype=mimetype)
@weather_sat_bp.route('/images/<filename>', methods=['DELETE'])
def delete_image(filename: str):
"""Delete a decoded image.
@@ -469,6 +523,62 @@ def delete_all_images():
return jsonify({'status': 'ok', 'deleted': count})
def _get_ground_station_images() -> list[dict]:
try:
from utils.database import get_db
with get_db() as conn:
rows = conn.execute(
'''
SELECT id, norad_id, file_path, metadata_json, created_at
FROM ground_station_outputs
WHERE output_type='image' AND backend='meteor_lrpt'
ORDER BY created_at DESC
LIMIT 200
'''
).fetchall()
except Exception as e:
logger.debug("Failed to fetch ground-station weather outputs: %s", e)
return []
images: list[dict] = []
for row in rows:
file_path = Path(row['file_path'])
if not file_path.exists():
continue
metadata = {}
raw_metadata = row['metadata_json']
if raw_metadata:
try:
metadata = json.loads(raw_metadata)
except json.JSONDecodeError:
metadata = {}
satellite = metadata.get('satellite') or _satellite_from_norad(row['norad_id'])
images.append({
'filename': file_path.name,
'satellite': satellite,
'mode': metadata.get('mode', 'LRPT'),
'timestamp': metadata.get('timestamp') or row['created_at'],
'frequency': metadata.get('frequency', 137.9),
'size_bytes': metadata.get('size_bytes') or file_path.stat().st_size,
'product': metadata.get('product', ''),
'url': f"/weather-sat/images/shared/{row['id']}",
'source': 'ground_station',
'deletable': False,
'output_id': row['id'],
})
return images
def _satellite_from_norad(norad_id: int | None) -> str:
for satellite, known_norad in METEOR_NORAD_IDS.items():
if known_norad == norad_id:
return satellite
return 'METEOR'
@weather_sat_bp.route('/stream')
def stream_progress():
"""SSE stream of capture/decode progress.
@@ -579,7 +689,7 @@ def enable_schedule():
"longitude": -0.1, // Required
"min_elevation": 15, // Minimum pass elevation (default: 15)
"device": 0, // RTL-SDR device index (default: 0)
"gain": 40.0, // SDR gain (default: 40)
"gain": 30.0, // SDR gain (default: 30)
"bias_t": false // Enable bias-T (default: false)
}
+12 -7
View File
@@ -673,13 +673,6 @@ def start_wifi_scan():
os.remove(f)
airodump_path = get_tool_path('airodump-ng')
cmd = [
airodump_path,
'-w', csv_path,
'--output-format', 'csv,pcap',
'--band', band,
interface
]
channel_list = None
if channels:
@@ -688,10 +681,22 @@ def start_wifi_scan():
except ValueError as e:
return api_error(str(e), 400)
cmd = [
airodump_path,
'-w', csv_path,
'--output-format', 'csv,pcap',
]
# --band and -c are mutually exclusive: only add --band when not
# locking to specific channels, and always place the interface last.
if channel_list:
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
elif channel:
cmd.extend(['-c', str(channel)])
else:
cmd.extend(['--band', band])
cmd.append(interface)
logger.info(f"Running: {' '.join(cmd)}")
+192 -146
View File
@@ -30,14 +30,15 @@ from utils.wifi import (
logger = logging.getLogger(__name__)
wifi_v2_bp = Blueprint('wifi_v2', __name__, url_prefix='/wifi/v2')
wifi_v2_bp = Blueprint("wifi_v2", __name__, url_prefix="/wifi/v2")
# =============================================================================
# Capabilities
# =============================================================================
@wifi_v2_bp.route('/capabilities', methods=['GET'])
@wifi_v2_bp.route("/capabilities", methods=["GET"])
def get_capabilities():
"""
Get WiFi scanning capabilities.
@@ -53,7 +54,8 @@ def get_capabilities():
# Quick Scan
# =============================================================================
@wifi_v2_bp.route('/scan/quick', methods=['POST'])
@wifi_v2_bp.route("/scan/quick", methods=["POST"])
def quick_scan():
"""
Perform a quick one-shot WiFi scan.
@@ -68,8 +70,8 @@ def quick_scan():
WiFiScanResult with discovered networks and channel analysis.
"""
data = request.get_json() or {}
interface = data.get('interface')
timeout = float(data.get('timeout', 15))
interface = data.get("interface")
timeout = float(data.get("timeout", 15))
scanner = get_wifi_scanner()
result = scanner.quick_scan(interface=interface, timeout=timeout)
@@ -81,7 +83,8 @@ def quick_scan():
# Deep Scan (Monitor Mode)
# =============================================================================
@wifi_v2_bp.route('/scan/start', methods=['POST'])
@wifi_v2_bp.route("/scan/start", methods=["POST"])
def start_deep_scan():
"""
Start a deep scan using airodump-ng.
@@ -95,15 +98,15 @@ def start_deep_scan():
channels: Optional list or comma-separated channels to monitor
"""
data = request.get_json() or {}
interface = data.get('interface')
band = data.get('band', 'all')
channel = data.get('channel')
channels = data.get('channels')
interface = data.get("interface")
band = data.get("band", "all")
channel = data.get("channel")
channels = data.get("channels")
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
channel_list = [c.strip() for c in channels.split(",") if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
@@ -111,13 +114,13 @@ def start_deep_scan():
try:
channel_list = [validate_wifi_channel(c) for c in channel_list]
except (TypeError, ValueError):
return api_error('Invalid channels', 400)
return api_error("Invalid channels", 400)
if channel:
try:
channel = validate_wifi_channel(channel)
except ValueError:
return api_error('Invalid channel', 400)
return api_error("Invalid channel", 400)
scanner = get_wifi_scanner()
success = scanner.start_deep_scan(
@@ -128,27 +131,31 @@ def start_deep_scan():
)
if success:
return jsonify({
'status': 'started',
'mode': SCAN_MODE_DEEP,
'interface': interface or scanner._capabilities.monitor_interface,
})
return jsonify(
{
"status": "started",
"mode": SCAN_MODE_DEEP,
"interface": interface or scanner._capabilities.monitor_interface,
}
)
else:
return api_error(scanner._status.error or 'Scan failed', 400)
return api_error(scanner._status.error or "Scan failed", 400)
@wifi_v2_bp.route('/scan/stop', methods=['POST'])
@wifi_v2_bp.route("/scan/stop", methods=["POST"])
def stop_deep_scan():
"""Stop the deep scan."""
scanner = get_wifi_scanner()
scanner.stop_deep_scan()
return jsonify({
'status': 'stopped',
})
return jsonify(
{
"status": "stopped",
}
)
@wifi_v2_bp.route('/scan/status', methods=['GET'])
@wifi_v2_bp.route("/scan/status", methods=["GET"])
def get_scan_status():
"""Get current scan status."""
scanner = get_wifi_scanner()
@@ -160,7 +167,8 @@ def get_scan_status():
# Data Endpoints
# =============================================================================
@wifi_v2_bp.route('/networks', methods=['GET'])
@wifi_v2_bp.route("/networks", methods=["GET"])
def get_networks():
"""
Get all discovered networks.
@@ -177,54 +185,60 @@ def get_networks():
scanner = get_wifi_scanner()
networks = scanner.access_points
# Apply filters
band = request.args.get('band')
if band:
networks = [n for n in networks if n.band == band]
security = request.args.get('security')
if security:
networks = [n for n in networks if n.security == security]
hidden = request.args.get('hidden')
if hidden == 'true':
networks = [n for n in networks if n.is_hidden]
elif hidden == 'false':
networks = [n for n in networks if not n.is_hidden]
min_rssi = request.args.get('min_rssi')
if min_rssi:
# Apply filters — single pass over the network list
band = request.args.get("band")
security = request.args.get("security")
hidden = request.args.get("hidden")
min_rssi_val: int | None = None
raw_min_rssi = request.args.get("min_rssi")
if raw_min_rssi:
try:
min_rssi = int(min_rssi)
networks = [n for n in networks if n.rssi_current and n.rssi_current >= min_rssi]
min_rssi_val = int(raw_min_rssi)
except ValueError:
pass
if band or security or hidden or min_rssi_val is not None:
def _matches(n: object) -> bool:
if band and n.band != band:
return False
if security and n.security != security:
return False
if hidden == "true" and not n.is_hidden:
return False
if hidden == "false" and n.is_hidden:
return False
if min_rssi_val is not None and (not n.rssi_current or n.rssi_current < min_rssi_val): # noqa: SIM103
return False
return True
networks = [n for n in networks if _matches(n)]
# Apply sorting
sort_field = request.args.get('sort', 'rssi')
order = request.args.get('order', 'desc')
reverse = order == 'desc'
sort_field = request.args.get("sort", "rssi")
order = request.args.get("order", "desc")
reverse = order == "desc"
sort_key_map = {
'rssi': lambda n: n.rssi_current or -100,
'channel': lambda n: n.channel or 0,
'essid': lambda n: (n.essid or '').lower(),
'last_seen': lambda n: n.last_seen,
'clients': lambda n: n.client_count,
"rssi": lambda n: n.rssi_current or -100,
"channel": lambda n: n.channel or 0,
"essid": lambda n: (n.essid or "").lower(),
"last_seen": lambda n: n.last_seen,
"clients": lambda n: n.client_count,
}
if sort_field in sort_key_map:
networks.sort(key=sort_key_map[sort_field], reverse=reverse)
# Format output
output_format = request.args.get('format', 'summary')
if output_format == 'full':
output_format = request.args.get("format", "summary")
if output_format == "full":
return jsonify([n.to_dict() for n in networks])
else:
return jsonify([n.to_summary_dict() for n in networks])
@wifi_v2_bp.route('/networks/<bssid>', methods=['GET'])
@wifi_v2_bp.route("/networks/<bssid>", methods=["GET"])
def get_network(bssid):
"""Get a specific network by BSSID."""
scanner = get_wifi_scanner()
@@ -233,10 +247,10 @@ def get_network(bssid):
if network:
return jsonify(network.to_dict())
else:
return api_error('Network not found', 404)
return api_error("Network not found", 404)
@wifi_v2_bp.route('/clients', methods=['GET'])
@wifi_v2_bp.route("/clients", methods=["GET"])
def get_clients():
"""
Get all discovered clients.
@@ -250,17 +264,17 @@ def get_clients():
clients = scanner.clients
# Apply filters
associated = request.args.get('associated')
if associated == 'true':
associated = request.args.get("associated")
if associated == "true":
clients = [c for c in clients if c.is_associated]
elif associated == 'false':
elif associated == "false":
clients = [c for c in clients if not c.is_associated]
bssid = request.args.get('bssid')
bssid = request.args.get("bssid")
if bssid:
clients = [c for c in clients if c.associated_bssid == bssid.upper()]
min_rssi = request.args.get('min_rssi')
min_rssi = request.args.get("min_rssi")
if min_rssi:
try:
min_rssi = int(min_rssi)
@@ -271,7 +285,7 @@ def get_clients():
return jsonify([c.to_dict() for c in clients])
@wifi_v2_bp.route('/clients/<mac>', methods=['GET'])
@wifi_v2_bp.route("/clients/<mac>", methods=["GET"])
def get_client(mac):
"""Get a specific client by MAC address."""
scanner = get_wifi_scanner()
@@ -280,10 +294,10 @@ def get_client(mac):
if client:
return jsonify(client.to_dict())
else:
return api_error('Client not found', 404)
return api_error("Client not found", 404)
@wifi_v2_bp.route('/probes', methods=['GET'])
@wifi_v2_bp.route("/probes", methods=["GET"])
def get_probes():
"""
Get captured probe requests.
@@ -297,16 +311,16 @@ def get_probes():
probes = scanner.probe_requests
# Apply filters
client_mac = request.args.get('client_mac')
client_mac = request.args.get("client_mac")
if client_mac:
probes = [p for p in probes if p.client_mac == client_mac.upper()]
ssid = request.args.get('ssid')
ssid = request.args.get("ssid")
if ssid:
probes = [p for p in probes if p.probed_ssid == ssid]
# Apply limit
limit = request.args.get('limit')
limit = request.args.get("limit")
if limit:
try:
limit = int(limit)
@@ -321,7 +335,8 @@ def get_probes():
# Channel Analysis
# =============================================================================
@wifi_v2_bp.route('/channels', methods=['GET'])
@wifi_v2_bp.route("/channels", methods=["GET"])
def get_channel_stats():
"""
Get channel utilization statistics and recommendations.
@@ -330,24 +345,27 @@ def get_channel_stats():
include_dfs: Include DFS channels in recommendations (true/false)
"""
scanner = get_wifi_scanner()
include_dfs = request.args.get('include_dfs', 'false') == 'true'
include_dfs = request.args.get("include_dfs", "false") == "true"
stats, recommendations = analyze_channels(
scanner.access_points,
include_dfs=include_dfs,
)
return jsonify({
'stats': [s.to_dict() for s in stats],
'recommendations': [r.to_dict() for r in recommendations],
})
return jsonify(
{
"stats": [s.to_dict() for s in stats],
"recommendations": [r.to_dict() for r in recommendations],
}
)
# =============================================================================
# Hidden SSID Correlation
# =============================================================================
@wifi_v2_bp.route('/hidden', methods=['GET'])
@wifi_v2_bp.route("/hidden", methods=["GET"])
def get_hidden_correlations():
"""
Get revealed hidden SSIDs from correlation.
@@ -362,35 +380,41 @@ def get_hidden_correlations():
# Baseline Management
# =============================================================================
@wifi_v2_bp.route('/baseline/set', methods=['POST'])
@wifi_v2_bp.route("/baseline/set", methods=["POST"])
def set_baseline():
"""Mark current networks as baseline (known networks)."""
scanner = get_wifi_scanner()
scanner.set_baseline()
return jsonify({
'status': 'baseline_set',
'network_count': len(scanner._baseline_networks),
'set_at': datetime.now().isoformat(),
})
return jsonify(
{
"status": "baseline_set",
"network_count": len(scanner._baseline_networks),
"set_at": datetime.now().isoformat(),
}
)
@wifi_v2_bp.route('/baseline/clear', methods=['POST'])
@wifi_v2_bp.route("/baseline/clear", methods=["POST"])
def clear_baseline():
"""Clear the baseline."""
scanner = get_wifi_scanner()
scanner.clear_baseline()
return jsonify({
'status': 'baseline_cleared',
})
return jsonify(
{
"status": "baseline_cleared",
}
)
# =============================================================================
# SSE Streaming
# =============================================================================
@wifi_v2_bp.route('/stream', methods=['GET'])
@wifi_v2_bp.route("/stream", methods=["GET"])
def event_stream():
"""
Server-Sent Events stream for real-time updates.
@@ -403,17 +427,18 @@ def event_stream():
- scan_started, scan_stopped, scan_error
- keepalive: Periodic keepalive
"""
def generate() -> Generator[str, None, None]:
scanner = get_wifi_scanner()
for event in scanner.get_event_stream():
with contextlib.suppress(Exception):
process_event('wifi', event, event.get('type'))
process_event("wifi", event, event.get("type"))
yield format_sse(event)
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response = Response(generate(), mimetype="text/event-stream")
response.headers["Cache-Control"] = "no-cache"
response.headers["X-Accel-Buffering"] = "no"
return response
@@ -421,22 +446,26 @@ def event_stream():
# Data Management
# =============================================================================
@wifi_v2_bp.route('/clear', methods=['POST'])
@wifi_v2_bp.route("/clear", methods=["POST"])
def clear_data():
"""Clear all discovered data."""
scanner = get_wifi_scanner()
scanner.clear_data()
return jsonify({
'status': 'cleared',
})
return jsonify(
{
"status": "cleared",
}
)
# =============================================================================
# Export
# =============================================================================
@wifi_v2_bp.route('/export', methods=['GET'])
@wifi_v2_bp.route("/export", methods=["GET"])
def export_data():
"""
Export scan data.
@@ -446,10 +475,10 @@ def export_data():
type: 'networks', 'clients', 'probes', 'all' (default: all)
"""
scanner = get_wifi_scanner()
export_format = request.args.get('format', 'json')
export_type = request.args.get('type', 'all')
export_format = request.args.get("format", "json")
export_type = request.args.get("type", "all")
if export_format == 'csv':
if export_format == "csv":
return _export_csv(scanner, export_type)
else:
return _export_json(scanner, export_type)
@@ -459,24 +488,26 @@ def _export_json(scanner, export_type: str) -> Response:
"""Export data as JSON."""
data = {}
if export_type in ('networks', 'all'):
data['networks'] = [n.to_dict() for n in scanner.access_points]
if export_type in ("networks", "all"):
data["networks"] = [n.to_dict() for n in scanner.access_points]
if export_type in ('clients', 'all'):
data['clients'] = [c.to_dict() for c in scanner.clients]
if export_type in ("clients", "all"):
data["clients"] = [c.to_dict() for c in scanner.clients]
if export_type in ('probes', 'all'):
data['probes'] = [p.to_dict() for p in scanner.probe_requests]
if export_type in ("probes", "all"):
data["probes"] = [p.to_dict() for p in scanner.probe_requests]
data['exported_at'] = datetime.now().isoformat()
data['network_count'] = len(scanner.access_points)
data['client_count'] = len(scanner.clients)
data["exported_at"] = datetime.now().isoformat()
data["network_count"] = len(scanner.access_points)
data["client_count"] = len(scanner.clients)
response = Response(
json.dumps(data, indent=2),
mimetype='application/json',
mimetype="application/json",
)
response.headers["Content-Disposition"] = (
f"attachment; filename=wifi_scan_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
)
response.headers['Content-Disposition'] = f'attachment; filename=wifi_scan_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
return response
@@ -484,51 +515,66 @@ def _export_csv(scanner, export_type: str) -> Response:
"""Export data as CSV."""
output = io.StringIO()
if export_type in ('networks', 'all'):
if export_type in ("networks", "all"):
writer = csv.writer(output)
writer.writerow([
'BSSID', 'ESSID', 'Channel', 'Band', 'RSSI', 'Security',
'Cipher', 'Auth', 'Vendor', 'Clients', 'First Seen', 'Last Seen'
])
writer.writerow(
[
"BSSID",
"ESSID",
"Channel",
"Band",
"RSSI",
"Security",
"Cipher",
"Auth",
"Vendor",
"Clients",
"First Seen",
"Last Seen",
]
)
for n in scanner.access_points:
writer.writerow([
n.bssid,
n.essid or '[Hidden]',
n.channel,
n.band,
n.rssi_current,
n.security,
n.cipher,
n.auth,
n.vendor or '',
n.client_count,
n.first_seen.isoformat(),
n.last_seen.isoformat(),
])
writer.writerow(
[
n.bssid,
n.essid or "[Hidden]",
n.channel,
n.band,
n.rssi_current,
n.security,
n.cipher,
n.auth,
n.vendor or "",
n.client_count,
n.first_seen.isoformat(),
n.last_seen.isoformat(),
]
)
if export_type == 'all':
if export_type == "all":
writer.writerow([]) # Blank line separator
if export_type in ('clients', 'all'):
if export_type in ("clients", "all"):
writer = csv.writer(output)
if export_type == 'clients':
writer.writerow([
'MAC', 'Vendor', 'RSSI', 'Associated BSSID', 'Probed SSIDs',
'First Seen', 'Last Seen'
])
if export_type == "clients":
writer.writerow(["MAC", "Vendor", "RSSI", "Associated BSSID", "Probed SSIDs", "First Seen", "Last Seen"])
for c in scanner.clients:
writer.writerow([
c.mac,
c.vendor or '',
c.rssi_current,
c.associated_bssid or '',
', '.join(c.probed_ssids),
c.first_seen.isoformat(),
c.last_seen.isoformat(),
])
writer.writerow(
[
c.mac,
c.vendor or "",
c.rssi_current,
c.associated_bssid or "",
", ".join(c.probed_ssids),
c.first_seen.isoformat(),
c.last_seen.isoformat(),
]
)
response = Response(output.getvalue(), mimetype='text/csv')
response.headers['Content-Disposition'] = f'attachment; filename=wifi_scan_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
response = Response(output.getvalue(), mimetype="text/csv")
response.headers["Content-Disposition"] = (
f"attachment; filename=wifi_scan_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
)
return response
+162
View File
@@ -0,0 +1,162 @@
"""Minimal semver compatibility shim.
This project vendors a tiny subset of the ``semver`` package API so
integrations like radiosonde_auto_rx can run even when the external
dependency is missing from the target Python environment.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, replace
from typing import Iterable
_SEMVER_RE = re.compile(
r"^\s*"
r"(?P<major>0|[1-9]\d*)"
r"(?:\.(?P<minor>0|[1-9]\d*))?"
r"(?:\.(?P<patch>0|[1-9]\d*))?"
r"(?:-(?P<prerelease>[0-9A-Za-z.-]+))?"
r"(?:\+(?P<build>[0-9A-Za-z.-]+))?"
r"\s*$"
)
def _split_prerelease(value: str | None) -> list[int | str]:
if not value:
return []
parts: list[int | str] = []
for token in value.split("."):
parts.append(int(token) if token.isdigit() else token)
return parts
def _compare_identifiers(left: Iterable[int | str], right: Iterable[int | str]) -> int:
left_parts = list(left)
right_parts = list(right)
for l_part, r_part in zip(left_parts, right_parts):
if l_part == r_part:
continue
if isinstance(l_part, int) and isinstance(r_part, str):
return -1
if isinstance(l_part, str) and isinstance(r_part, int):
return 1
return -1 if l_part < r_part else 1
if len(left_parts) == len(right_parts):
return 0
return -1 if len(left_parts) < len(right_parts) else 1
@dataclass(frozen=True)
class VersionInfo:
major: int
minor: int = 0
patch: int = 0
prerelease: str | None = None
build: str | None = None
@classmethod
def parse(cls, version: str) -> VersionInfo:
match = _SEMVER_RE.match(str(version))
if not match:
raise ValueError(f"{version!r} is not valid SemVer")
groups = match.groupdict()
return cls(
major=int(groups["major"]),
minor=int(groups["minor"] or 0),
patch=int(groups["patch"] or 0),
prerelease=groups["prerelease"],
build=groups["build"],
)
@classmethod
def isvalid(cls, version: str) -> bool:
return _SEMVER_RE.match(str(version)) is not None
@classmethod
def is_valid(cls, version: str) -> bool:
return cls.isvalid(version)
def compare(self, other: str | VersionInfo) -> int:
return compare(self, other)
def match(self, expr: str) -> bool:
return match(str(self), expr)
def bump_major(self) -> VersionInfo:
return VersionInfo(self.major + 1, 0, 0)
def bump_minor(self) -> VersionInfo:
return VersionInfo(self.major, self.minor + 1, 0)
def bump_patch(self) -> VersionInfo:
return VersionInfo(self.major, self.minor, self.patch + 1)
def finalize_version(self) -> VersionInfo:
return VersionInfo(self.major, self.minor, self.patch)
def replace(self, **changes) -> VersionInfo:
return replace(self, **changes)
def __str__(self) -> str:
value = f"{self.major}.{self.minor}.{self.patch}"
if self.prerelease:
value += f"-{self.prerelease}"
if self.build:
value += f"+{self.build}"
return value
def parse(version: str) -> VersionInfo:
return VersionInfo.parse(version)
def compare(left: str | VersionInfo, right: str | VersionInfo) -> int:
left_ver = left if isinstance(left, VersionInfo) else parse(str(left))
right_ver = right if isinstance(right, VersionInfo) else parse(str(right))
left_core = (left_ver.major, left_ver.minor, left_ver.patch)
right_core = (right_ver.major, right_ver.minor, right_ver.patch)
if left_core != right_core:
return -1 if left_core < right_core else 1
if left_ver.prerelease == right_ver.prerelease:
return 0
if left_ver.prerelease is None:
return 1
if right_ver.prerelease is None:
return -1
return _compare_identifiers(
_split_prerelease(left_ver.prerelease),
_split_prerelease(right_ver.prerelease),
)
def match(version: str | VersionInfo, expr: str) -> bool:
version_info = version if isinstance(version, VersionInfo) else parse(str(version))
expression = str(expr).strip()
for operator in ("<=", ">=", "==", "!=", "<", ">"):
if expression.startswith(operator):
other = parse(expression[len(operator):].strip())
result = compare(version_info, other)
return {
"<": result < 0,
"<=": result <= 0,
">": result > 0,
">=": result >= 0,
"==": result == 0,
"!=": result != 0,
}[operator]
return compare(version_info, parse(expression)) == 0
def max_ver(left: str | VersionInfo, right: str | VersionInfo) -> VersionInfo:
left_ver = left if isinstance(left, VersionInfo) else parse(str(left))
right_ver = right if isinstance(right, VersionInfo) else parse(str(right))
return left_ver if compare(left_ver, right_ver) >= 0 else right_ver
def min_ver(left: str | VersionInfo, right: str | VersionInfo) -> VersionInfo:
left_ver = left if isinstance(left, VersionInfo) else parse(str(left))
right_ver = right if isinstance(right, VersionInfo) else parse(str(right))
return left_ver if compare(left_ver, right_ver) <= 0 else right_ver
+99 -20
View File
@@ -438,7 +438,11 @@ check_tools() {
check_optional "dumpvdl2" "VDL2 decoder" dumpvdl2
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
check_optional "auto_rx.py" "Radiosonde weather balloon decoder" auto_rx.py
if [[ -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ]] && [[ -f /opt/radiosonde_auto_rx/auto_rx/dft_detect ]]; then
ok "auto_rx.py - Radiosonde weather balloon decoder"
else
warn "auto_rx.py - Radiosonde weather balloon decoder (missing, optional)"
fi
echo
info "GPS:"
check_required "gpsd" "GPS daemon" gpsd
@@ -487,6 +491,16 @@ import sys
raise SystemExit(0 if sys.version_info >= (3,9) else 1)
PY
ok "Python version OK (>= 3.9)"
# 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.
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)."
fi
}
install_python_deps() {
@@ -520,8 +534,11 @@ install_python_deps() {
source venv/bin/activate
local PIP="venv/bin/python -m pip"
local PY="venv/bin/python"
# --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"
if ! $PIP install --upgrade pip setuptools wheel; then
if ! $PIP install $PIP_OPTS --upgrade pip setuptools wheel; then
warn "pip/setuptools/wheel upgrade failed - continuing with existing versions"
else
ok "Upgraded pip tooling"
@@ -530,24 +547,39 @@ install_python_deps() {
progress "Installing Python dependencies"
info "Installing core packages..."
$PIP install --quiet "flask>=3.0.0" "flask-limiter>=2.5.4" "requests>=2.28.0" \
"Werkzeug>=3.1.5" "pyserial>=3.5" 2>/dev/null || true
$PIP install $PIP_OPTS "flask>=3.0.0" "flask-wtf>=1.2.0" "flask-compress>=1.15" \
"flask-limiter>=2.5.4" "requests>=2.28.0" \
"Werkzeug>=3.1.5" "pyserial>=3.5" || true
$PY -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
echo "Try: venv/bin/pip install flask requests flask-limiter"
exit 1
}
# Verify core packages are installed by checking pip's reported list (avoids hanging imports)
for core_pkg in flask requests flask-limiter flask-compress flask-wtf; do
if ! $PIP show "$core_pkg" >/dev/null 2>&1; then
fail "Critical Python package not installed: ${core_pkg}"
echo "Try: venv/bin/pip install ${core_pkg}"
exit 1
fi
done
ok "Core Python packages installed"
info "Installing optional packages..."
for pkg in "flask-sock" "websocket-client>=1.6.0" "numpy>=1.24.0" "scipy>=1.10.0" \
"Pillow>=9.0.0" "skyfield>=1.45" "bleak>=0.21.0" "psycopg2-binary>=2.9.9" \
"meshtastic>=2.0.0" "scapy>=2.4.5" "qrcode[pil]>=7.4" "cryptography>=41.0.0" \
"gunicorn>=21.2.0" "gevent>=23.9.0" "psutil>=5.9.0"; do
pkg_name="${pkg%%>=*}"
# 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%%[><=]*}"
info " Installing ${pkg_name}..."
if ! $PIP install "$pkg"; then
if ! $PIP install $PIP_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
@@ -603,7 +635,25 @@ apt_install() {
fi
}
wait_for_apt_lock() {
local max_wait=120
local waited=0
while fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1; do
if [[ $waited -eq 0 ]]; then
info "Waiting for apt lock (another package manager is running)..."
fi
sleep 5
waited=$((waited + 5))
if [[ $waited -ge $max_wait ]]; then
warn "apt lock held for over ${max_wait}s. Continuing anyway (may fail)."
return 1
fi
done
return 0
}
apt_try_install_any() {
wait_for_apt_lock
local p
for p in "$@"; do
if $SUDO apt-get install -y --no-install-recommends "$p" >/dev/null 2>&1; then
@@ -751,9 +801,26 @@ install_acarsdec_from_source_macos() {
cd "$tmp_dir/acarsdec"
# Replace deprecated -Ofast (all macOS, not just arm64)
if grep -q '\-Ofast' CMakeLists.txt 2>/dev/null; then
sed -i '' 's/-Ofast/-O3 -ffast-math/g' CMakeLists.txt
info "Patched deprecated -Ofast flag"
fi
# macOS doesn't have -march=native on arm64
if [[ "$(uname -m)" == "arm64" ]]; then
sed -i '' 's/-Ofast -march=native/-O3 -ffast-math/g' CMakeLists.txt
info "Patched compiler flags for Apple Silicon (arm64)"
sed -i '' 's/ -march=native//g' CMakeLists.txt
info "Removed -march=native for Apple Silicon"
fi
# HOST_NAME_MAX is Linux-specific; macOS uses _POSIX_HOST_NAME_MAX
if grep -q 'HOST_NAME_MAX' acarsdec.c 2>/dev/null; then
sed -i '' '1i\
#ifndef HOST_NAME_MAX\
#define HOST_NAME_MAX 255\
#endif
' acarsdec.c
info "Patched HOST_NAME_MAX for macOS compatibility"
fi
if grep -q 'pthread_tryjoin_np' rtl.c 2>/dev/null; then
@@ -1703,6 +1770,7 @@ install_profiles() {
export NEEDRESTART_MODE=a
fi
wait_for_apt_lock
info "Updating APT package lists..."
if ! $SUDO apt-get update -y >/dev/null 2>&1; then
warn "apt-get update reported errors. Continuing anyway."
@@ -1924,7 +1992,18 @@ do_health_check() {
info "SDR device detection..."
if cmd_exists rtl_test; then
local rtl_output
rtl_output=$(timeout 3 rtl_test -d 0 2>&1 || true)
if cmd_exists timeout; then
rtl_output=$(timeout 3 rtl_test -d 0 2>&1 || true)
elif cmd_exists gtimeout; then
rtl_output=$(gtimeout 3 rtl_test -d 0 2>&1 || true)
else
# No timeout command (common on macOS) — run with background kill
rtl_test -d 0 > /tmp/.rtl_test_out 2>&1 & local rtl_pid=$!
sleep 2
kill "$rtl_pid" 2>/dev/null; wait "$rtl_pid" 2>/dev/null
rtl_output=$(cat /tmp/.rtl_test_out 2>/dev/null || true)
rm -f /tmp/.rtl_test_out
fi
if echo "$rtl_output" | grep -q "Found\|Using device"; then
ok "RTL-SDR device detected"
((pass++)) || true
@@ -1984,8 +2063,8 @@ do_health_check() {
ok "Python venv exists"
((pass++)) || true
if venv/bin/python -c "import flask; import requests" 2>/dev/null; then
ok "Critical Python packages (flask, requests) — OK"
if venv/bin/python -s -c "import flask; import requests; import flask_compress; import flask_wtf" 2>/dev/null; then
ok "Critical Python packages (flask, requests, flask-compress, flask-wtf) — OK"
((pass++)) || true
else
fail "Critical Python packages missing in venv"
+78 -40
View File
@@ -21,7 +21,7 @@
--accent-red: #e25d5d;
--accent-yellow: #e1c26b;
--accent-amber: #d6a85e;
--grid-line: rgba(74, 163, 255, 0.1);
--grid-line: rgba(var(--accent-cyan-rgb), 0.1);
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
--radar-cyan: #4aa3ff;
--radar-bg: #101823;
@@ -329,7 +329,7 @@ body {
}
.acars-collapse-btn:hover {
background: rgba(74, 158, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.2);
}
.acars-collapse-label {
@@ -447,7 +447,7 @@ body {
}
.acars-message-item:hover {
background: rgba(74, 158, 255, 0.05);
background: rgba(var(--accent-cyan-rgb), 0.05);
}
@keyframes fadeIn {
@@ -490,7 +490,7 @@ body {
}
.vdl2-collapse-btn:hover {
background: rgba(74, 158, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.2);
}
.vdl2-collapse-label {
@@ -610,7 +610,7 @@ body {
}
.vdl2-message-item:hover {
background: rgba(74, 158, 255, 0.08);
background: rgba(var(--accent-cyan-rgb), 0.08);
}
/* VDL2 Message Modal */
@@ -787,7 +787,7 @@ body {
/* Panels */
.panel {
background: var(--bg-panel);
border: 1px solid rgba(74, 158, 255, 0.2);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.2);
overflow: hidden;
position: relative;
}
@@ -804,8 +804,8 @@ body {
.panel-header {
padding: 10px 15px;
background: rgba(74, 158, 255, 0.05);
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
background: rgba(var(--accent-cyan-rgb), 0.05);
border-bottom: 1px solid rgba(var(--accent-cyan-rgb), 0.1);
font-family: 'Orbitron', monospace;
font-size: 11px;
font-weight: 500;
@@ -984,7 +984,7 @@ body {
display: flex;
flex-direction: column;
border-left: none;
border-top: 1px solid rgba(74, 158, 255, 0.2);
border-top: 1px solid rgba(var(--accent-cyan-rgb), 0.2);
overflow: hidden;
max-height: 40vh;
}
@@ -993,7 +993,7 @@ body {
.sidebar {
grid-column: 2;
grid-row: 1;
border-left: 1px solid rgba(74, 158, 255, 0.2);
border-left: 1px solid rgba(var(--accent-cyan-rgb), 0.2);
border-top: none;
max-height: none;
}
@@ -1031,7 +1031,7 @@ body {
width: 100%;
object-fit: cover;
border-radius: 6px;
border: 1px solid rgba(0, 212, 255, 0.3);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.3);
}
.selected-callsign {
@@ -1088,7 +1088,7 @@ body {
.aircraft-item {
position: relative;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(74, 158, 255, 0.15);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.15);
border-radius: 4px;
padding: 8px 10px;
margin-bottom: 6px;
@@ -1098,13 +1098,13 @@ body {
.aircraft-item:hover {
border-color: var(--accent-cyan);
background: rgba(74, 158, 255, 0.05);
background: rgba(var(--accent-cyan-rgb), 0.05);
}
.aircraft-item.selected {
border-color: var(--accent-cyan);
box-shadow: 0 0 15px rgba(74, 158, 255, 0.2);
background: rgba(74, 158, 255, 0.1);
box-shadow: 0 0 15px rgba(var(--accent-cyan-rgb), 0.2);
background: rgba(var(--accent-cyan-rgb), 0.1);
}
.aircraft-header {
@@ -1125,7 +1125,7 @@ body {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-secondary);
background: rgba(74, 158, 255, 0.1);
background: rgba(var(--accent-cyan-rgb), 0.1);
padding: 2px 5px;
border-radius: 3px;
}
@@ -1177,8 +1177,8 @@ body {
align-items: flex-start;
gap: 4px;
padding: 6px 10px;
background: rgba(74, 158, 255, 0.03);
border: 1px solid rgba(74, 158, 255, 0.1);
background: rgba(var(--accent-cyan-rgb), 0.03);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.1);
border-radius: 6px;
}
@@ -1207,8 +1207,8 @@ body {
align-items: flex-start;
gap: 4px;
padding: 6px 10px;
background: rgba(74, 158, 255, 0.03);
border: 1px solid rgba(74, 158, 255, 0.1);
background: rgba(var(--accent-cyan-rgb), 0.03);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.1);
border-radius: 6px;
}
@@ -1247,7 +1247,7 @@ body {
.control-group select {
padding: 4px 8px;
background: var(--bg-dark);
border: 1px solid rgba(74, 158, 255, 0.3);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: var(--font-mono);
@@ -1258,7 +1258,7 @@ body {
.control-group input[type="number"] {
padding: 4px 6px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(74, 158, 255, 0.3);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: var(--font-mono);
@@ -1322,7 +1322,7 @@ body {
width: 40px;
height: 4px;
-webkit-appearance: none;
background: rgba(74, 158, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.2);
border-radius: 2px;
}
@@ -1376,8 +1376,8 @@ body {
/* GPS button */
.gps-btn {
padding: 6px 10px;
background: rgba(74, 158, 255, 0.2);
border: 1px solid rgba(74, 158, 255, 0.3);
background: rgba(var(--accent-cyan-rgb), 0.2);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: var(--font-mono);
@@ -1677,7 +1677,7 @@ body {
0% {
transform: translate(-50%, -50%) scale(0.8);
opacity: 1;
border-color: rgba(74, 158, 255, 1);
border-color: rgba(var(--accent-cyan-rgb), 1);
}
50% {
opacity: 0.6;
@@ -1685,7 +1685,7 @@ body {
100% {
transform: translate(-50%, -50%) scale(1.8);
opacity: 0;
border-color: rgba(74, 158, 255, 0);
border-color: rgba(var(--accent-cyan-rgb), 0);
}
}
@@ -1868,15 +1868,15 @@ body {
flex-direction: column;
align-items: center;
padding: 4px 10px;
background: rgba(74, 158, 255, 0.05);
border: 1px solid rgba(74, 158, 255, 0.15);
background: rgba(var(--accent-cyan-rgb), 0.05);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.15);
border-radius: 4px;
min-width: 55px;
}
.strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
background: rgba(var(--accent-cyan-rgb), 0.1);
border-color: rgba(var(--accent-cyan-rgb), 0.3);
}
.strip-value {
@@ -1925,7 +1925,7 @@ body {
.strip-report-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(74, 158, 255, 0.3);
box-shadow: 0 4px 12px rgba(var(--accent-cyan-rgb), 0.3);
}
/* ============================================
@@ -2111,7 +2111,7 @@ body {
}
.report-table tr:hover {
background: rgba(74, 158, 255, 0.05);
background: rgba(var(--accent-cyan-rgb), 0.05);
}
.report-more {
@@ -2183,7 +2183,7 @@ body {
.strip-divider {
width: 1px;
height: 24px;
background: rgba(74, 158, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.2);
margin: 0 4px;
}
@@ -2193,8 +2193,8 @@ body {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.1);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.2);
color: var(--text-primary);
padding: 6px 10px;
border-radius: 4px;
@@ -2212,8 +2212,8 @@ body {
}
.strip-btn:hover:not(:disabled) {
background: rgba(74, 158, 255, 0.2);
border-color: rgba(74, 158, 255, 0.4);
background: rgba(var(--accent-cyan-rgb), 0.2);
border-color: rgba(var(--accent-cyan-rgb), 0.4);
}
.strip-btn:disabled {
@@ -2227,9 +2227,13 @@ body {
color: var(--text-inverse);
}
html[data-ui-tier="enhanced"] .strip-btn.primary {
background: linear-gradient(135deg, rgba(46, 125, 138, 0.85) 0%, rgba(20, 88, 100, 0.80) 100%);
}
.strip-btn.primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(74, 158, 255, 0.3);
box-shadow: 0 4px 12px rgba(var(--accent-cyan-rgb), 0.3);
}
/* Status and time in strip */
@@ -2272,7 +2276,7 @@ body {
color: var(--accent-cyan);
font-family: var(--font-mono);
padding-left: 8px;
border-left: 1px solid rgba(74, 158, 255, 0.2);
border-left: 1px solid rgba(var(--accent-cyan-rgb), 0.2);
white-space: nowrap;
}
@@ -2460,3 +2464,37 @@ body {
font-size: 11px;
}
}
/* ============================================
ENHANCED TIER signals teal console
============================================ */
html[data-ui-tier="enhanced"] {
--bg-dark: #000000;
--bg-panel: #020404;
--bg-card: #020404;
--radar-bg: #020404;
--radar-cyan: #2e7d8a;
--border-glow: rgba(46, 125, 138, 0.20);
--border-color: rgba(46, 125, 138, 0.18);
--grid-line: rgba(46, 125, 138, 0.07);
--accent-cyan: #2e7d8a;
}
/* ============================================
LEAN TIER flat dark, no GPU effects
============================================ */
html[data-ui-tier="lean"] {
--bg-dark: #111111;
--bg-panel: #181818;
--bg-card: #1a1a1a;
--radar-bg: #181818;
--border-glow: transparent;
--border-color: #2a2a2a;
--grid-line: transparent;
--noise-image: none;
}
html[data-ui-tier="lean"] .scanline,
html[data-ui-tier="lean"] .radar-bg {
display: none;
}
+33 -8
View File
@@ -11,14 +11,14 @@
--bg-panel: #101823;
--bg-card: #151f2b;
--border-color: #263246;
--border-glow: rgba(74, 163, 255, 0.4);
--border-glow: rgba(var(--accent-cyan-rgb), 0.4);
--text-primary: #d7e0ee;
--text-secondary: #9fb0c7;
--text-dim: #6f7f94;
--accent-cyan: #4aa3ff;
--accent-green: #38c180;
--accent-amber: #d6a85e;
--grid-line: rgba(74, 163, 255, 0.1);
--grid-line: rgba(var(--accent-cyan-rgb), 0.1);
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
}
@@ -297,7 +297,7 @@ body {
.primary-btn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 14px rgba(74, 158, 255, 0.3);
box-shadow: 0 6px 14px rgba(var(--accent-cyan-rgb), 0.3);
}
.primary-btn:disabled {
@@ -411,7 +411,7 @@ body {
}
.aircraft-row:hover {
background: rgba(74, 158, 255, 0.1);
background: rgba(var(--accent-cyan-rgb), 0.1);
}
.aircraft-row.military {
@@ -440,9 +440,9 @@ body {
}
.civ-badge {
background: rgba(74, 158, 255, 0.15);
background: rgba(var(--accent-cyan-rgb), 0.15);
color: var(--text-dim);
border: 1px solid rgba(74, 158, 255, 0.25);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.25);
}
.mono {
@@ -602,8 +602,8 @@ body {
}
.nav-btn {
background: rgba(74, 158, 255, 0.15);
border: 1px solid rgba(74, 158, 255, 0.4);
background: rgba(var(--accent-cyan-rgb), 0.15);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.4);
color: var(--accent-cyan);
padding: 6px 10px;
border-radius: 6px;
@@ -721,3 +721,28 @@ body {
grid-template-columns: 1fr;
}
}
html[data-ui-tier="enhanced"] {
--accent-cyan: #2e7d8a;
--accent-cyan-rgb: 46, 125, 138;
--border-color: rgba(46, 125, 138, 0.18);
--border-glow: rgba(46, 125, 138, 0.20);
--grid-line: rgba(46, 125, 138, 0.07);
--bg-dark: #000000;
--bg-panel: #020404;
--bg-card: #020404;
}
html[data-ui-tier="enhanced"] body {
background: #000000;
}
html[data-ui-tier="enhanced"] .session-strip {
background: linear-gradient(120deg, rgba(4, 8, 8, 0.95), rgba(6, 10, 10, 0.95));
}
html[data-ui-tier="lean"] {
--border-color: #2a2a2a;
--border-glow: transparent;
--grid-line: transparent;
}
+8 -8
View File
@@ -10,15 +10,15 @@
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
background: rgba(var(--accent-cyan-rgb), 0.1);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.3);
border-radius: 20px;
cursor: pointer;
transition: all 0.2s;
}
.agent-indicator:hover {
background: rgba(0, 212, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.2);
border-color: var(--accent-cyan);
}
@@ -49,7 +49,7 @@
.agent-indicator-count {
font-size: 10px;
padding: 2px 6px;
background: rgba(0, 212, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.2);
border-radius: 10px;
color: var(--accent-cyan);
}
@@ -123,11 +123,11 @@
}
.agent-selector-item:hover {
background: rgba(0, 212, 255, 0.1);
background: rgba(var(--accent-cyan-rgb), 0.1);
}
.agent-selector-item.selected {
background: rgba(0, 212, 255, 0.15);
background: rgba(var(--accent-cyan-rgb), 0.15);
border-left: 3px solid var(--accent-cyan);
}
@@ -188,7 +188,7 @@
gap: 4px;
padding: 2px 8px;
font-size: 10px;
background: rgba(0, 212, 255, 0.1);
background: rgba(var(--accent-cyan-rgb), 0.1);
color: var(--accent-cyan);
border-radius: 10px;
font-family: var(--font-mono);
@@ -201,7 +201,7 @@
}
.agent-badge.agent-remote {
background: rgba(0, 212, 255, 0.1);
background: rgba(var(--accent-cyan-rgb), 0.1);
color: var(--accent-cyan);
}
+66 -32
View File
@@ -24,7 +24,7 @@
--accent-red: #e25d5d;
--accent-yellow: #e1c26b;
--accent-amber: #d6a85e;
--grid-line: rgba(74, 163, 255, 0.1);
--grid-line: rgba(var(--accent-cyan-rgb), 0.1);
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
--radar-cyan: #4aa3ff;
--radar-bg: #101823;
@@ -249,15 +249,15 @@ body {
flex-direction: column;
align-items: center;
padding: 4px 10px;
background: rgba(74, 158, 255, 0.05);
border: 1px solid rgba(74, 158, 255, 0.15);
background: rgba(var(--accent-cyan-rgb), 0.05);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.15);
border-radius: 4px;
min-width: 55px;
}
.strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
background: rgba(var(--accent-cyan-rgb), 0.1);
border-color: rgba(var(--accent-cyan-rgb), 0.3);
}
.strip-value {
@@ -321,7 +321,7 @@ body {
.strip-divider {
width: 1px;
height: 24px;
background: rgba(74, 158, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.2);
margin: 0 4px;
}
@@ -367,15 +367,15 @@ body {
color: var(--accent-cyan);
font-family: var(--font-mono);
padding-left: 8px;
border-left: 1px solid rgba(74, 158, 255, 0.2);
border-left: 1px solid rgba(var(--accent-cyan-rgb), 0.2);
white-space: nowrap;
}
.strip-btn {
position: relative;
z-index: 10;
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.1);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.2);
color: var(--text-primary);
padding: 6px 10px;
border-radius: 4px;
@@ -387,8 +387,8 @@ body {
}
.strip-btn:hover:not(:disabled) {
background: rgba(74, 158, 255, 0.2);
border-color: rgba(74, 158, 255, 0.4);
background: rgba(var(--accent-cyan-rgb), 0.2);
border-color: rgba(var(--accent-cyan-rgb), 0.4);
}
.strip-btn.primary {
@@ -467,7 +467,7 @@ body {
background: var(--bg-panel);
color: var(--text-primary);
border-radius: 4px;
border: 1px solid rgba(74, 158, 255, 0.2);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.2);
}
.leaflet-popup-tip {
@@ -479,7 +479,7 @@ body {
display: flex;
flex-direction: column;
border-left: none;
border-top: 1px solid rgba(74, 158, 255, 0.2);
border-top: 1px solid rgba(var(--accent-cyan-rgb), 0.2);
overflow: hidden;
max-height: 40vh;
background: var(--bg-panel);
@@ -489,7 +489,7 @@ body {
.sidebar {
grid-column: 2;
grid-row: 1;
border-left: 1px solid rgba(74, 158, 255, 0.2);
border-left: 1px solid rgba(var(--accent-cyan-rgb), 0.2);
border-top: none;
max-height: none;
}
@@ -498,7 +498,7 @@ body {
/* Panels */
.panel {
background: var(--bg-panel);
border: 1px solid rgba(74, 158, 255, 0.2);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.2);
overflow: hidden;
position: relative;
}
@@ -515,8 +515,8 @@ body {
.panel-header {
padding: 10px 15px;
background: rgba(74, 158, 255, 0.05);
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
background: rgba(var(--accent-cyan-rgb), 0.05);
border-bottom: 1px solid rgba(var(--accent-cyan-rgb), 0.1);
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 11px;
font-weight: 500;
@@ -557,7 +557,7 @@ body {
flex-shrink: 0;
max-height: 480px;
overflow-y: auto;
border-bottom: 1px solid rgba(74, 158, 255, 0.2);
border-bottom: 1px solid rgba(var(--accent-cyan-rgb), 0.2);
}
.selected-info {
@@ -600,7 +600,7 @@ body {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
background: rgba(74, 158, 255, 0.1);
background: rgba(var(--accent-cyan-rgb), 0.1);
padding: 2px 5px;
border-radius: 3px;
}
@@ -649,7 +649,7 @@ body {
.vessel-item {
position: relative;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(74, 158, 255, 0.15);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.15);
border-radius: 4px;
padding: 8px 10px;
margin-bottom: 6px;
@@ -662,13 +662,13 @@ body {
.vessel-item:hover {
border-color: var(--accent-cyan);
background: rgba(74, 158, 255, 0.05);
background: rgba(var(--accent-cyan-rgb), 0.05);
}
.vessel-item.selected {
border-color: var(--accent-cyan);
box-shadow: 0 0 15px rgba(74, 158, 255, 0.2);
background: rgba(74, 158, 255, 0.1);
box-shadow: 0 0 15px rgba(var(--accent-cyan-rgb), 0.2);
background: rgba(var(--accent-cyan-rgb), 0.1);
}
.vessel-item-icon {
@@ -712,7 +712,7 @@ body {
gap: 8px;
padding: 8px 15px;
background: var(--bg-panel);
border-top: 1px solid rgba(74, 158, 255, 0.3);
border-top: 1px solid rgba(var(--accent-cyan-rgb), 0.3);
font-size: 11px;
overflow: hidden;
}
@@ -725,8 +725,8 @@ body {
align-items: flex-start;
gap: 4px;
padding: 6px 10px;
background: rgba(74, 158, 255, 0.03);
border: 1px solid rgba(74, 158, 255, 0.1);
background: rgba(var(--accent-cyan-rgb), 0.03);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.1);
border-radius: 6px;
}
@@ -740,8 +740,8 @@ body {
align-items: flex-start;
gap: 4px;
padding: 6px 10px;
background: rgba(74, 158, 255, 0.03);
border: 1px solid rgba(74, 158, 255, 0.1);
background: rgba(var(--accent-cyan-rgb), 0.03);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.1);
border-radius: 6px;
}
@@ -780,7 +780,7 @@ body {
.control-group select {
padding: 4px 8px;
background: var(--bg-dark);
border: 1px solid rgba(74, 158, 255, 0.3);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: var(--font-mono);
@@ -791,7 +791,7 @@ body {
.control-group input[type="number"] {
padding: 4px 6px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(74, 158, 255, 0.3);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: var(--font-mono);
@@ -889,7 +889,7 @@ body {
0% {
transform: translate(-50%, -50%) scale(0.8);
opacity: 1;
border-color: rgba(74, 158, 255, 1);
border-color: rgba(var(--accent-cyan-rgb), 1);
}
50% {
opacity: 0.6;
@@ -897,7 +897,7 @@ body {
100% {
transform: translate(-50%, -50%) scale(1.8);
opacity: 0;
border-color: rgba(74, 158, 255, 0);
border-color: rgba(var(--accent-cyan-rgb), 0);
}
}
@@ -1373,3 +1373,37 @@ body {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
/* ============================================
ENHANCED TIER signals teal console
============================================ */
html[data-ui-tier="enhanced"] {
--bg-dark: #000000;
--bg-panel: #020404;
--bg-card: #020404;
--radar-bg: #020404;
--radar-cyan: #2e7d8a;
--border-glow: rgba(46, 125, 138, 0.20);
--border-color: rgba(46, 125, 138, 0.18);
--grid-line: rgba(46, 125, 138, 0.07);
--accent-cyan: #2e7d8a;
}
/* ============================================
LEAN TIER flat dark, no GPU effects
============================================ */
html[data-ui-tier="lean"] {
--bg-dark: #111111;
--bg-panel: #181818;
--bg-card: #1a1a1a;
--radar-bg: #181818;
--border-glow: transparent;
--border-color: #2a2a2a;
--grid-line: transparent;
--noise-image: none;
}
html[data-ui-tier="lean"] .scanline,
html[data-ui-tier="lean"] .radar-bg {
display: none;
}
+1 -1
View File
@@ -38,7 +38,7 @@
.device-card:hover {
border-color: var(--accent-cyan, #00d4ff);
box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2);
box-shadow: 0 0 0 1px rgba(var(--accent-cyan-rgb), 0.2);
}
.device-card:active {
+12 -12
View File
@@ -36,15 +36,15 @@
flex-direction: column;
align-items: center;
padding: 6px 10px;
background: rgba(74, 158, 255, 0.05);
border: 1px solid rgba(74, 158, 255, 0.15);
background: rgba(var(--accent-cyan-rgb), 0.05);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.15);
border-radius: 4px;
min-width: 55px;
}
.function-strip .strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
background: rgba(var(--accent-cyan-rgb), 0.1);
border-color: rgba(var(--accent-cyan-rgb), 0.3);
}
.function-strip .strip-value {
@@ -165,8 +165,8 @@
/* Buttons */
.function-strip .strip-btn {
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.1);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.2);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 4px;
@@ -178,8 +178,8 @@
}
.function-strip .strip-btn:hover:not(:disabled) {
background: rgba(74, 158, 255, 0.2);
border-color: rgba(74, 158, 255, 0.4);
background: rgba(var(--accent-cyan-rgb), 0.2);
border-color: rgba(var(--accent-cyan-rgb), 0.4);
}
.function-strip .strip-btn.primary {
@@ -365,12 +365,12 @@
}
.function-strip.listening-strip .strip-stat {
background: rgba(74, 158, 255, 0.05);
border-color: rgba(74, 158, 255, 0.15);
background: rgba(var(--accent-cyan-rgb), 0.05);
border-color: rgba(var(--accent-cyan-rgb), 0.15);
}
.function-strip.listening-strip .strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
background: rgba(var(--accent-cyan-rgb), 0.1);
border-color: rgba(var(--accent-cyan-rgb), 0.3);
}
.function-strip.listening-strip .strip-value {
color: var(--accent-cyan);
+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);
}
+2 -6
View File
@@ -41,10 +41,6 @@
}
}
.radar-sweep {
transform-origin: 50% 50%;
}
/* Radar filter buttons */
.bt-radar-filter-btn {
transition: all 0.2s ease;
@@ -166,8 +162,8 @@
}
.heatmap-row.selected {
background: rgba(0, 212, 255, 0.1);
outline: 1px solid rgba(0, 212, 255, 0.3);
background: rgba(var(--accent-cyan-rgb), 0.1);
outline: 1px solid rgba(var(--accent-cyan-rgb), 0.3);
}
.heatmap-header {
+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);
}
+7 -7
View File
@@ -153,7 +153,7 @@
}
.signal-filter-btn.active .signal-filter-count {
background: rgba(74, 158, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.2);
color: var(--accent-cyan);
}
@@ -268,7 +268,7 @@
.signal-proto-badge.pocsag {
background: var(--accent-cyan-dim);
color: var(--accent-cyan);
border-color: rgba(74, 158, 255, 0.25);
border-color: rgba(var(--accent-cyan-rgb), 0.25);
}
.signal-proto-badge.flex {
@@ -557,12 +557,12 @@
.signal-action-btn.primary {
background: var(--accent-cyan-dim);
border-color: rgba(74, 158, 255, 0.25);
border-color: rgba(var(--accent-cyan-rgb), 0.25);
color: var(--accent-cyan);
}
.signal-action-btn.primary:hover {
background: rgba(74, 158, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.2);
}
.signal-action-btn.danger {
@@ -691,8 +691,8 @@
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(74, 158, 255, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(74, 158, 255, 0.08) 1px, transparent 1px);
linear-gradient(rgba(var(--accent-cyan-rgb), 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(var(--accent-cyan-rgb), 0.08) 1px, transparent 1px);
background-size: 14px 14px;
}
@@ -1645,7 +1645,7 @@
.signal-card.signal-card-clickable:hover {
border-color: var(--accent-cyan, #00d4ff);
box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2);
box-shadow: 0 0 0 1px rgba(var(--accent-cyan-rgb), 0.2);
}
.signal-card.signal-card-clickable:active {
+2 -2
View File
@@ -501,8 +501,8 @@
}
.update-result-info {
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.3);
background: rgba(var(--accent-cyan-rgb), 0.1);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.3);
}
.update-result-info .update-result-icon {
+46 -10
View File
@@ -7,7 +7,7 @@
gap: 10px;
padding: 8px 14px;
margin: 6px 12px 0;
border: 1px solid rgba(74, 163, 255, 0.32);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.32);
border-radius: 8px;
background: linear-gradient(180deg, rgba(19, 30, 44, 0.96), rgba(11, 18, 28, 0.97));
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.04);
@@ -42,7 +42,7 @@
gap: 6px;
padding: 3px 7px;
border-radius: 999px;
border: 1px solid rgba(74, 163, 255, 0.25);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.25);
background: linear-gradient(180deg, rgba(17, 26, 38, 0.82), rgba(12, 18, 28, 0.84));
font-size: 10px;
color: var(--text-secondary, #b1c2d4);
@@ -63,9 +63,9 @@
}
.run-state-chip.active {
border-color: rgba(74, 163, 255, 0.65);
border-color: rgba(var(--accent-cyan-rgb), 0.65);
color: var(--text-primary, #e6edf5);
box-shadow: inset 0 0 0 1px rgba(74, 163, 255, 0.18);
box-shadow: inset 0 0 0 1px rgba(var(--accent-cyan-rgb), 0.18);
}
.run-state-right {
@@ -82,7 +82,7 @@
.run-state-btn {
background: linear-gradient(180deg, rgba(17, 27, 41, 0.9), rgba(10, 16, 25, 0.92));
color: var(--accent-cyan, #4aa3ff);
border: 1px solid rgba(74, 163, 255, 0.45);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.45);
border-radius: 6px;
font-size: 10px;
padding: 4px 8px;
@@ -91,8 +91,8 @@
}
.run-state-btn:hover {
background: rgba(74, 163, 255, 0.14);
border-color: rgba(74, 163, 255, 0.7);
background: rgba(var(--accent-cyan-rgb), 0.14);
border-color: rgba(var(--accent-cyan-rgb), 0.7);
transform: translateY(-1px);
}
@@ -114,7 +114,7 @@
.command-palette {
width: min(760px, 100%);
border: 1px solid rgba(74, 163, 255, 0.32);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.32);
border-radius: 12px;
background: linear-gradient(180deg, rgba(16, 26, 39, 0.98), rgba(10, 17, 27, 0.98));
box-shadow: 0 26px 60px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.04);
@@ -198,7 +198,7 @@
.command-palette-item.active,
.command-palette-item:hover,
.command-palette-item:focus-visible {
background: rgba(74, 163, 255, 0.12);
background: rgba(var(--accent-cyan-rgb), 0.12);
outline: none;
}
@@ -402,10 +402,46 @@
}
.app-toast-actions button:hover {
border-color: rgba(74, 163, 255, 0.5);
border-color: rgba(var(--accent-cyan-rgb), 0.5);
color: var(--text-primary, #e6edf5);
}
/* ---- Enhanced tier overrides ---- */
html[data-ui-tier="enhanced"] .run-state-strip {
background: linear-gradient(180deg, rgba(4, 8, 8, 0.96), rgba(2, 4, 4, 0.97));
border-color: rgba(46, 125, 138, 0.28);
}
html[data-ui-tier="enhanced"] .run-state-chip {
background: linear-gradient(180deg, rgba(4, 8, 8, 0.82), rgba(2, 4, 4, 0.84));
border-color: rgba(46, 125, 138, 0.22);
}
html[data-ui-tier="enhanced"] .run-state-chip.active {
border-color: rgba(46, 125, 138, 0.60);
box-shadow: inset 0 0 0 1px rgba(46, 125, 138, 0.16);
}
html[data-ui-tier="enhanced"] .run-state-chip .dot {
background: rgba(46, 125, 138, 0.40);
box-shadow: none;
}
html[data-ui-tier="enhanced"] .run-state-chip.running .dot {
background: var(--accent-green, #38c180);
box-shadow: 0 0 0 4px rgba(56, 193, 128, 0.16), 0 0 12px rgba(56, 193, 128, 0.35);
}
html[data-ui-tier="enhanced"] .run-state-btn {
background: linear-gradient(180deg, rgba(4, 8, 8, 0.9), rgba(2, 4, 4, 0.92));
border-color: rgba(46, 125, 138, 0.40);
}
html[data-ui-tier="enhanced"] .run-state-btn:hover {
background: rgba(46, 125, 138, 0.12);
border-color: rgba(46, 125, 138, 0.65);
}
/* ---- Light theme overrides ---- */
[data-theme="light"] .run-state-chip {
background: linear-gradient(180deg, rgba(233, 238, 245, 0.9), rgba(225, 232, 242, 0.92));
+32
View File
@@ -434,3 +434,35 @@ a:hover {
--text-secondary: #d1d5db;
}
}
/* ============================================
LEAN TIER strip body background and animations
============================================ */
[data-ui-tier="lean"] body {
background-image: none;
background-attachment: unset;
background-size: unset;
}
[data-ui-tier="lean"] *,
[data-ui-tier="lean"] *::before,
[data-ui-tier="lean"] *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0ms !important;
scroll-behavior: auto !important;
}
/* ============================================
ENHANCED TIER tighter grid background
============================================ */
[data-ui-tier="enhanced"] body {
background-image:
radial-gradient(1200px 620px at 8% -12%, var(--ambient-top-left), transparent 62%),
radial-gradient(980px 560px at 92% -16%, var(--ambient-top-right), transparent 64%),
radial-gradient(900px 520px at 50% 126%, var(--ambient-bottom), transparent 68%),
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: auto, auto, auto, 20px 20px, 20px 20px;
background-attachment: fixed;
}
+87 -6
View File
@@ -136,11 +136,10 @@
============================================ */
.card {
background: var(--surface-panel-gradient);
border: 1px solid rgba(74, 163, 255, 0.24);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.24);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(5px);
}
.card-header {
@@ -148,7 +147,7 @@
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid rgba(74, 163, 255, 0.18);
border-bottom: 1px solid rgba(var(--accent-cyan-rgb), 0.18);
background: linear-gradient(180deg, rgba(25, 38, 55, 0.88) 0%, rgba(17, 27, 40, 0.9) 100%);
position: relative;
}
@@ -174,11 +173,10 @@
/* Panel variant (used in dashboards) */
.panel {
background: var(--surface-panel-gradient);
border: 1px solid rgba(74, 163, 255, 0.24);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.24);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(5px);
}
@supports (clip-path: polygon(0 0)) {
@@ -204,7 +202,7 @@
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid rgba(74, 163, 255, 0.18);
border-bottom: 1px solid rgba(var(--accent-cyan-rgb), 0.18);
background: linear-gradient(180deg, rgba(25, 38, 55, 0.88) 0%, rgba(17, 27, 40, 0.9) 100%);
font-size: var(--text-xs);
font-weight: var(--font-semibold);
@@ -233,9 +231,21 @@
background: var(--status-offline);
}
@keyframes panel-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.panel-indicator.active {
background: var(--status-online);
box-shadow: 0 0 8px var(--status-online);
animation: panel-pulse 2s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.panel-indicator.active {
animation: none;
}
}
.panel-content {
@@ -1152,3 +1162,74 @@ textarea:focus {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
}
/* Visuals Container Base Styles */
.visuals-container {
position: relative;
}
.visuals-container::after {
content: '';
position: absolute;
inset: 0;
background: var(--scanline);
pointer-events: none;
z-index: 1;
border-radius: inherit;
}
/* ============================================
LEAN TIER flat cards, no shadows/glows
============================================ */
[data-ui-tier="lean"] .card,
[data-ui-tier="lean"] .panel,
[data-ui-tier="lean"] .stat-card,
[data-ui-tier="lean"] .data-card {
box-shadow: none;
border-radius: 2px;
}
[data-ui-tier="lean"] .panel-indicator.active {
animation: none;
box-shadow: none;
}
[data-ui-tier="lean"] .scanline,
[data-ui-tier="lean"] .landing-scanline,
[data-ui-tier="lean"] .welcome-scanline,
[data-ui-tier="lean"] .radar-bg,
[data-ui-tier="lean"] .grid-bg,
[data-ui-tier="lean"] .globe-background,
[data-ui-tier="lean"] .signal-wave,
[data-ui-tier="lean"] .visuals-container::after {
display: none;
}
/* ============================================
ENHANCED TIER signals teal card accents
============================================ */
[data-ui-tier="enhanced"] .stat-card,
[data-ui-tier="enhanced"] .data-card {
border-left: 2px solid var(--accent-cyan);
background: rgba(46, 125, 138, 0.03);
}
[data-ui-tier="enhanced"] .stat-value,
[data-ui-tier="enhanced"] .data-value {
font-family: var(--font-mono);
color: var(--accent-cyan);
}
[data-ui-tier="enhanced"] .stat-label,
[data-ui-tier="enhanced"] .data-label {
font-family: var(--font-mono);
letter-spacing: 2px;
text-transform: uppercase;
font-size: var(--text-xs);
color: rgba(46, 125, 138, 0.55);
}
html[data-ui-tier="enhanced"] .card-header,
html[data-ui-tier="enhanced"] .panel-header {
background: linear-gradient(180deg, rgba(4, 8, 8, 0.88) 0%, rgba(2, 4, 4, 0.9) 100%);
}
+122 -32
View File
@@ -88,8 +88,11 @@
}
/* Branded "i" inline SVG that matches the logo icon.
Sized to 0.9em so it sits naturally alongside text at any font-size. */
.brand-i {
Sized to 0.9em so it sits naturally alongside text at any font-size.
Uses .logo .brand-i (0,2,0) to beat .logo span (0,1,1) in dashboard CSS
which otherwise forces display:inline and breaks width/height. */
.brand-i,
.logo .brand-i {
display: inline-block;
width: 0.55em;
height: 0.9em;
@@ -738,16 +741,17 @@
}
.mode-nav-btn:hover {
background: var(--bg-elevated);
background: var(--accent-cyan-glow);
color: var(--text-primary);
border-color: var(--border-color);
}
.mode-nav-btn.active {
background: var(--bg-elevated);
background: var(--accent-cyan-glow);
color: var(--text-primary);
border-color: var(--accent-cyan);
box-shadow: inset 0 -2px 0 var(--accent-cyan);
border-left: 2px solid var(--accent-cyan);
box-shadow: -2px 0 8px rgba(var(--accent-cyan-rgb), 0.2);
padding-left: 12px; /* compensate for 2px border */
}
.mode-nav-btn.active .nav-icon {
@@ -835,7 +839,7 @@
}
.mode-nav-dropdown-btn:hover {
background: var(--bg-elevated);
background: var(--accent-cyan-glow);
color: var(--text-primary);
border-color: var(--border-color);
}
@@ -851,10 +855,11 @@
}
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
background: var(--bg-elevated);
background: var(--accent-cyan-glow);
color: var(--text-primary);
border-color: var(--accent-cyan);
box-shadow: inset 0 -2px 0 var(--accent-cyan);
border-left: 2px solid var(--accent-cyan);
box-shadow: -2px 0 8px rgba(var(--accent-cyan-rgb), 0.2);
padding-left: 12px;
}
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
@@ -898,9 +903,11 @@
}
.mode-nav-dropdown-menu .mode-nav-btn.active {
background: var(--bg-elevated);
background: var(--accent-cyan-glow);
color: var(--text-primary);
box-shadow: inset 0 -2px 0 var(--accent-cyan);
border-left: 2px solid var(--accent-cyan);
box-shadow: -2px 0 6px rgba(var(--accent-cyan-rgb), 0.15);
padding-left: 10px;
}
/* Focus-visible states for nav elements */
@@ -1034,19 +1041,6 @@
transform: rotate(90deg);
}
/* Effects/animations toggle icon states */
.nav-tool-btn .icon-effects-off {
display: none;
}
[data-animations="off"] .nav-tool-btn .icon-effects-on {
display: none;
}
[data-animations="off"] .nav-tool-btn .icon-effects-off {
display: flex;
}
/* Dashboard Button in Nav */
a.nav-dashboard-btn,
a.nav-dashboard-btn:link,
@@ -1100,15 +1094,22 @@ a.nav-dashboard-btn:hover {
}
[data-theme="light"] .mode-nav-btn.active {
background: rgba(220, 230, 244, 0.9);
color: var(--text-primary);
background: rgba(31, 95, 168, 0.08);
border-left: 2px solid var(--accent-cyan);
box-shadow: -2px 0 6px rgba(31, 95, 168, 0.15);
padding-left: 12px;
}
[data-theme="light"] .mode-nav-dropdown-btn:hover,
[data-theme="light"] .mode-nav-dropdown.open .mode-nav-dropdown-btn,
[data-theme="light"] .mode-nav-dropdown.open .mode-nav-dropdown-btn {
background: rgba(31, 95, 168, 0.06);
}
[data-theme="light"] .mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
background: rgba(220, 230, 244, 0.9);
color: var(--text-primary);
background: rgba(31, 95, 168, 0.06);
border-left: 2px solid var(--accent-cyan);
box-shadow: -2px 0 6px rgba(31, 95, 168, 0.12);
padding-left: 12px;
}
[data-theme="light"] .mode-nav-dropdown-menu {
@@ -1121,8 +1122,9 @@ a.nav-dashboard-btn:hover {
}
[data-theme="light"] .mode-nav-dropdown-menu .mode-nav-btn.active {
background: rgba(220, 230, 244, 0.95);
color: var(--text-primary);
background: rgba(31, 95, 168, 0.08);
border-left: 2px solid var(--accent-cyan);
padding-left: 10px;
}
[data-theme="light"] .nav-tool-btn {
@@ -1157,3 +1159,91 @@ a.nav-dashboard-btn:hover {
background: rgba(220, 230, 244, 0.9);
box-shadow: var(--shadow-sm);
}
/* ============================================
LEAN TIER flat nav and header
============================================ */
[data-ui-tier="lean"] .mode-nav {
background: #181818;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
[data-ui-tier="lean"] .mode-nav::after,
[data-ui-tier="lean"] .app-header::after,
[data-ui-tier="lean"] .mobile-nav::after,
[data-ui-tier="lean"] .dashboard-header::after {
display: none;
}
[data-ui-tier="lean"] .app-header {
background: #181818;
box-shadow: none;
}
[data-ui-tier="lean"] .mode-nav-dropdown-menu {
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: #202020;
}
/* ============================================
ENHANCED TIER signals teal console nav framing
============================================ */
[data-ui-tier="enhanced"] .mode-nav {
background: linear-gradient(180deg, rgba(4, 8, 8, 0.95), rgba(2, 4, 4, 0.92));
}
[data-ui-tier="enhanced"] .mode-nav::after,
[data-ui-tier="enhanced"] .app-header::after,
[data-ui-tier="enhanced"] .dashboard-header::after {
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
}
[data-ui-tier="enhanced"] .mode-nav-btn.active {
background: rgba(46, 125, 138, 0.08);
color: var(--accent-cyan);
border-left: 2px solid var(--accent-cyan);
box-shadow: -2px 0 8px rgba(46, 125, 138, 0.15);
padding-left: 12px;
}
[data-ui-tier="enhanced"] .nav-clock .utc-time {
color: var(--accent-cyan);
text-shadow: 0 0 8px rgba(46, 125, 138, 0.28);
font-weight: 700;
}
[data-ui-tier="enhanced"] .mode-nav-btn .nav-label,
[data-ui-tier="enhanced"] .mode-nav-label {
font-family: var(--font-mono);
letter-spacing: 2px;
}
/* Tier toggle button — label visibility */
.nav-tier-btn .tier-label-lean { display: none; }
.nav-tier-btn .tier-label-enhanced { display: none; }
[data-ui-tier="lean"] .nav-tier-btn .tier-label-lean { display: inline; }
[data-ui-tier="enhanced"] .nav-tier-btn .tier-label-enhanced { display: inline; }
/* Tier toggle — icon visibility */
.nav-tier-btn .icon-tier-lean { display: flex; }
.nav-tier-btn .icon-tier-enhanced { display: none; }
[data-ui-tier="lean"] .nav-tier-btn .icon-tier-lean { display: flex; }
[data-ui-tier="lean"] .nav-tier-btn .icon-tier-enhanced { display: none; }
[data-ui-tier="enhanced"] .nav-tier-btn .icon-tier-lean { display: none; }
[data-ui-tier="enhanced"] .nav-tier-btn .icon-tier-enhanced { display: flex; }
/* Enhanced tier toggle button styling */
[data-ui-tier="enhanced"] .nav-tier-btn {
background: rgba(46, 125, 138, 0.10);
border-color: rgba(46, 125, 138, 0.38);
color: #2e7d8a;
box-shadow: 0 0 8px rgba(46, 125, 138, 0.07);
text-shadow: 0 0 6px rgba(46, 125, 138, 0.25);
}
[data-ui-tier="enhanced"] .nav-tier-btn:hover {
background: rgba(46, 125, 138, 0.16);
border-color: rgba(46, 125, 138, 0.55);
}
+171
View File
@@ -0,0 +1,171 @@
/* ============================================================
MAP UTILS Tactical overlay styles
Used by all map-using pages via map-utils.js
============================================================ */
/* --- HUD panel base ---
Absolutely positioned dark-glass panels over the Leaflet map container.
The map container already has position:relative set by Leaflet. */
.map-hud-panel {
position: absolute;
z-index: 1000;
padding: 6px 10px;
background: rgba(7, 9, 14, 0.72);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.18);
border-radius: 4px;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 11px;
color: var(--text-secondary, #8ba0b8);
pointer-events: none;
display: flex;
align-items: center;
gap: 8px;
line-height: 1.4;
}
/* Top-left: mode name + contact count */
.map-hud-tl {
top: 10px;
left: 10px;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.map-hud-mode {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-dim, #5a7080);
}
.map-hud-count {
font-size: 16px;
font-weight: 600;
color: var(--accent-cyan, #4aa3ff);
line-height: 1;
}
/* Top-right: UTC clock + status dot */
.map-hud-tr {
top: 10px;
right: 10px;
gap: 6px;
}
.map-hud-clock {
color: var(--text-secondary, #8ba0b8);
font-size: 11px;
}
.map-hud-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-dim, #5a7080);
flex-shrink: 0;
}
.map-hud-dot.online {
background: var(--status-online, #38c180);
box-shadow: 0 0 4px var(--status-online, #38c180);
}
.map-hud-dot.offline {
background: var(--status-error, #e85d5d);
}
/* --- Observer reticle ---
Rendered as a Leaflet divIcon; no extra CSS needed beyond pointer-events. */
.map-reticle {
pointer-events: none !important;
background: none !important;
border: none !important;
}
/* --- Range ring labels --- */
.map-range-label {
pointer-events: none !important;
background: none !important;
border: none !important;
}
.map-range-label span {
display: inline-block;
background: rgba(7, 9, 14, 0.7);
color: rgba(var(--accent-cyan-rgb), 0.7);
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 9px;
padding: 1px 4px;
border-radius: 2px;
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. */
.map-glass-popup .leaflet-popup-content-wrapper {
background: var(--bg-elevated, #161d28) !important;
border: 1px solid var(--border-color, rgba(var(--accent-cyan-rgb),0.15)) !important;
border-radius: 6px !important;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
padding: 0;
}
.map-glass-popup .leaflet-popup-content {
margin: 0;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 11px;
color: var(--text-primary, #c8d8e8);
}
.map-glass-popup .leaflet-popup-tip-container {
display: none;
}
.map-glass-popup .leaflet-popup-close-button {
color: var(--text-dim, #5a7080);
font-size: 16px;
padding: 4px 6px;
}
.map-glass-popup .leaflet-popup-close-button:hover {
color: var(--text-primary, #c8d8e8);
}
+165 -26
View File
@@ -10,15 +10,15 @@
============================================ */
/* Backgrounds - layered depth system */
--bg-primary: #0b1118;
--bg-secondary: #101823;
--bg-tertiary: #151f2b;
--bg-card: #121a25;
--bg-elevated: #1b2734;
--bg-primary: #07090e;
--bg-secondary: #0b1018;
--bg-tertiary: #101520;
--bg-card: #0d1219;
--bg-elevated: #161d28;
--bg-overlay: rgba(8, 13, 20, 0.75);
--surface-glass: rgba(16, 25, 37, 0.82);
--surface-panel-gradient: linear-gradient(160deg, rgba(20, 32, 47, 0.94) 0%, rgba(11, 18, 27, 0.96) 100%);
--ambient-top-left: rgba(74, 163, 255, 0.14);
--ambient-top-left: rgba(var(--accent-cyan-rgb), 0.14);
--ambient-top-right: rgba(56, 193, 128, 0.09);
--ambient-bottom: rgba(214, 168, 94, 0.06);
@@ -28,8 +28,11 @@
/* Accent colors */
--accent-cyan: #4aa3ff;
--accent-cyan-dim: rgba(74, 163, 255, 0.16);
--accent-cyan-rgb: 74, 163, 255;
--primary-color: var(--accent-cyan);
--accent-cyan-dim: rgba(var(--accent-cyan-rgb), 0.16);
--accent-cyan-hover: #6bb3ff;
--accent-cyan-glow: rgba(var(--accent-cyan-rgb), 0.12);
--accent-green: #38c180;
--accent-green-hover: #16a34a;
--accent-green-dim: rgba(56, 193, 128, 0.18);
@@ -42,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;
@@ -53,7 +58,7 @@
/* Borders */
--border-color: #263246;
--border-light: #354458;
--border-glow: rgba(74, 163, 255, 0.25);
--border-glow: rgba(var(--accent-cyan-rgb), 0.25);
--border-focus: var(--accent-cyan);
/* Status colors */
@@ -76,10 +81,19 @@
--neon-red: #ff3366;
/* Subtle grid/pattern */
--grid-line: rgba(74, 163, 255, 0.1);
--grid-line: rgba(var(--accent-cyan-rgb), 0.1);
--grid-dot: rgba(255, 255, 255, 0.03);
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
/* Scanline overlay texture */
--scanline: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.04) 2px,
rgba(0, 0, 0, 0.04) 4px
);
/* ============================================
SPACING SCALE
============================================ */
@@ -97,7 +111,7 @@
/* ============================================
TYPOGRAPHY
============================================ */
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-sans: 'Inter', 'Roboto Condensed', 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', Consolas, monospace;
/* Font sizes */
@@ -136,7 +150,7 @@
--shadow-sm: 0 1px 1px rgba(0, 0, 0, 0.35);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 12px 18px rgba(0, 0, 0, 0.45);
--shadow-glow: 0 0 18px rgba(74, 163, 255, 0.16);
--shadow-glow: 0 0 18px rgba(var(--accent-cyan-rgb), 0.16);
/* ============================================
TRANSITIONS
@@ -168,9 +182,93 @@
}
/* ============================================
LIGHT THEME OVERRIDES
REDUCED MOTION
============================================ */
[data-theme="light"] {
@media (prefers-reduced-motion: reduce) {
:root {
--transition-fast: 0ms;
--transition-base: 0ms;
--transition-slow: 0ms;
}
}
/* ============================================
LEAN TIER flat dark, no GPU effects
============================================ */
html[data-ui-tier="lean"] {
--bg-primary: #111111;
--bg-secondary: #181818;
--bg-tertiary: #1f1f1f;
--bg-card: #1a1a1a;
--bg-elevated: #202020;
--bg-overlay: rgba(17, 17, 17, 0.92);
--surface-glass: #181818;
--surface-panel-gradient: #181818;
--ambient-top-left: transparent;
--ambient-top-right: transparent;
--ambient-bottom: transparent;
--border-color: #2a2a2a;
--border-light: #333333;
--border-glow: transparent;
--shadow-sm: 0 1px 1px rgba(0, 0, 0, 0.3);
--shadow-md: 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.3);
--shadow-glow: none;
--transition-fast: 0ms;
--transition-base: 0ms;
--transition-slow: 0ms;
--scanline: none;
--grid-line: transparent;
--noise-image: none;
}
/* ============================================
ENHANCED TIER signals teal console
============================================ */
html[data-ui-tier="enhanced"] {
--bg-primary: #000000;
--bg-secondary: #020404;
--bg-tertiary: #040808;
--bg-card: #020404;
--bg-elevated: #060a0a;
--bg-overlay: rgba(0, 0, 0, 0.88);
--surface-glass: rgba(2, 4, 4, 0.90);
--surface-panel-gradient: linear-gradient(160deg, rgba(6, 10, 10, 0.96) 0%, rgba(2, 4, 4, 0.98) 100%);
--accent-cyan: #2e7d8a;
--accent-cyan-rgb: 46, 125, 138;
--accent-cyan-dim: rgba(46, 125, 138, 0.14);
--accent-cyan-hover: #3a9aaa;
--accent-cyan-glow: rgba(46, 125, 138, 0.09);
/* red/green intentionally unchanged — semantic status only */
--ambient-top-left: rgba(46, 125, 138, 0.07);
--ambient-top-right: rgba(46, 125, 138, 0.04);
--ambient-bottom: rgba(46, 125, 138, 0.03);
--grid-line: rgba(46, 125, 138, 0.07);
--border-color: rgba(46, 125, 138, 0.18);
--border-light: rgba(46, 125, 138, 0.28);
--border-glow: rgba(46, 125, 138, 0.20);
--border-focus: #2e7d8a;
--status-info: #2e7d8a;
--font-sans: 'Inter', 'Roboto Condensed', 'Helvetica Neue', Arial, sans-serif;
}
/* ============================================
LIGHT THEME OVERRIDES
Placed after tier blocks so html[data-theme="light"]
(specificity 0,1,1) beats both tier selectors when active.
============================================ */
html[data-theme="light"] {
--bg-primary: #f4f7fb;
--bg-secondary: #e9eef5;
--bg-tertiary: #dde5f0;
@@ -183,11 +281,11 @@
--ambient-top-right: rgba(31, 138, 87, 0.06);
--ambient-bottom: rgba(181, 134, 58, 0.05);
/* Background aliases for components */
--bg-dark: var(--bg-primary);
--bg-panel: var(--bg-secondary);
--accent-cyan: #1f5fa8;
--accent-cyan-rgb: 31, 95, 168;
--accent-cyan-dim: rgba(31, 95, 168, 0.12);
--accent-cyan-hover: #2c73bf;
--accent-green: #1f8a57;
@@ -203,20 +301,17 @@
--accent-yellow: #9a8420;
--accent-purple: #6b5ba8;
/* Status colors - light theme */
--status-online: #1f8a57;
--status-warning: #b5863a;
--status-error: #c74444;
--status-offline: #6b7c93;
--status-info: #1f5fa8;
/* Severity colors */
--severity-critical: #c74444;
--severity-high: #b5863a;
--severity-medium: #9a8420;
--severity-low: #1f8a57;
/* Data visualization neon replacements */
--neon-green: #1a8a50;
--neon-yellow: #9a8420;
--neon-orange: #b5863a;
@@ -236,19 +331,63 @@
--grid-dot: rgba(12, 18, 24, 0.06);
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
--accent-cyan-glow: rgba(31, 95, 168, 0.08);
--scanline: none;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
--shadow-glow: 0 0 18px rgba(31, 95, 168, 0.1);
}
/* ============================================
REDUCED MOTION
============================================ */
@media (prefers-reduced-motion: reduce) {
:root {
--transition-fast: 0ms;
--transition-base: 0ms;
--transition-slow: 0ms;
}
/* Lean tier + light: specificity (0,2,1) beats lean-only (0,1,1) */
html[data-ui-tier="lean"][data-theme="light"] {
--bg-primary: #f4f7fb;
--bg-secondary: #e9eef5;
--bg-tertiary: #dde5f0;
--bg-card: #ffffff;
--bg-elevated: #f1f4f9;
--bg-dark: #f4f7fb;
--bg-panel: #e9eef5;
--bg-overlay: rgba(244, 247, 251, 0.92);
--surface-glass: rgba(255, 255, 255, 0.84);
--surface-panel-gradient: linear-gradient(160deg, rgba(255, 255, 255, 0.96) 0%, rgba(241, 245, 251, 0.97) 100%);
--accent-cyan: #1f5fa8;
--accent-cyan-rgb: 31, 95, 168;
--accent-cyan-dim: rgba(31, 95, 168, 0.12);
--accent-green: #1f8a57;
--text-primary: #122034;
--text-secondary: #3a4a5f;
--text-dim: #566a7f;
--text-muted: #7a8a9e;
--border-color: #d1d9e6;
--border-light: #c1ccdb;
--border-glow: rgba(31, 95, 168, 0.12);
--grid-line: rgba(31, 95, 168, 0.14);
--ambient-top-left: rgba(31, 95, 168, 0.1);
--ambient-top-right: rgba(31, 138, 87, 0.06);
--ambient-bottom: rgba(181, 134, 58, 0.05);
}
/* Enhanced tier + light: cool whites with signals teal accents */
html[data-ui-tier="enhanced"][data-theme="light"] {
--bg-primary: #f4f7fb;
--bg-secondary: #e9eef5;
--bg-tertiary: #dde5f0;
--bg-card: #ffffff;
--bg-elevated: #f1f4f9;
--bg-dark: #f4f7fb;
--bg-panel: #e9eef5;
--bg-overlay: rgba(244, 247, 251, 0.92);
--surface-glass: rgba(255, 255, 255, 0.84);
--text-primary: #0a1a1e;
--text-secondary: #1c3a42;
--text-dim: #3a5a62;
--border-color: rgba(30, 100, 112, 0.28);
--grid-line: rgba(30, 100, 112, 0.12);
--accent-cyan: #1e6470;
--accent-cyan-rgb: 30, 100, 112;
--accent-cyan-dim: rgba(30, 100, 112, 0.12);
--accent-cyan-hover: #25808e;
--accent-cyan-glow: rgba(30, 100, 112, 0.08);
}
+67 -1
View File
@@ -1,6 +1,6 @@
/* Local font declarations for offline mode */
/* Roboto Condensed - variable font, one file covers all weights */
/* Roboto Condensed - variable font, one file covers all weights */
@font-face {
font-family: 'Roboto Condensed';
font-style: normal;
@@ -18,3 +18,69 @@
src: url('/static/vendor/fonts/RobotoCondensed-LatinExt.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Inter - used by enhanced tier */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/static/vendor/fonts/Inter-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/static/vendor/fonts/Inter-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/static/vendor/fonts/Inter-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/static/vendor/fonts/Inter-Bold.woff2') format('woff2');
}
/* JetBrains Mono - used by enhanced tier and all --font-mono elements */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-Bold.woff2') format('woff2');
}
+683 -454
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -28,7 +28,7 @@
transition: background 0.2s;
}
.main-acars-collapse-btn:hover {
background: rgba(74, 158, 255, 0.15);
background: rgba(var(--accent-cyan-rgb), 0.15);
}
.main-acars-collapse-label {
writing-mode: vertical-rl;
@@ -67,7 +67,7 @@
animation: fadeInMsg 0.3s ease;
}
.main-acars-msg:hover {
background: rgba(74, 158, 255, 0.05);
background: rgba(var(--accent-cyan-rgb), 0.05);
}
@keyframes fadeInMsg {
from { opacity: 0; transform: translateY(-3px); }
@@ -86,8 +86,8 @@
background: var(--accent-red) !important;
}
@keyframes acars-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(74, 158, 255, 0.3); }
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 */
@@ -106,10 +106,10 @@
transition: background 0.15s;
}
.acars-feed-card:hover {
background: rgba(74, 158, 255, 0.05);
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(74, 158, 255, 0.1);
background: rgba(var(--accent-cyan-rgb), 0.1);
}
+11 -11
View File
@@ -18,14 +18,14 @@
flex-direction: column;
align-items: center;
padding: 4px 10px;
background: rgba(74, 158, 255, 0.05);
border: 1px solid rgba(74, 158, 255, 0.15);
background: rgba(var(--accent-cyan-rgb), 0.05);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.15);
border-radius: 4px;
min-width: 55px;
}
.aprs-strip .strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
background: rgba(var(--accent-cyan-rgb), 0.1);
border-color: rgba(var(--accent-cyan-rgb), 0.3);
}
.aprs-strip .strip-value {
font-family: var(--font-mono);
@@ -114,8 +114,8 @@
/* Buttons */
.aprs-strip .strip-btn {
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.1);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.2);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 4px;
@@ -126,8 +126,8 @@
white-space: nowrap;
}
.aprs-strip .strip-btn:hover:not(:disabled) {
background: rgba(74, 158, 255, 0.2);
border-color: rgba(74, 158, 255, 0.4);
background: rgba(var(--accent-cyan-rgb), 0.2);
border-color: rgba(var(--accent-cyan-rgb), 0.4);
}
.aprs-strip .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
@@ -223,8 +223,8 @@
.aprs-status-dot.tracking { background: var(--accent-green); }
.aprs-status-dot.error { background: var(--accent-red); }
@keyframes aprs-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); }
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 8px 4px rgba(var(--accent-cyan-rgb), 0.3); }
}
.aprs-status-text {
font-size: 10px;
@@ -339,7 +339,7 @@
gap: 5px;
padding: 2px 7px 2px 5px;
border-radius: 999px;
border: 1px solid rgba(74, 158, 255, 0.35);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.35);
background: rgba(10, 18, 28, 0.88);
color: var(--text-primary);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
+4 -4
View File
@@ -284,7 +284,7 @@
#btLocateMap {
position: absolute;
inset: 0;
background: #1a1a2e;
background: var(--bg-primary, #07090e);
}
.btl-map-overlay-controls {
@@ -442,8 +442,8 @@
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent-cyan, #00d4ff);
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
background: rgba(var(--accent-cyan-rgb), 0.1);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.3);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
@@ -452,7 +452,7 @@
}
.btl-detect-irk-btn:hover {
background: rgba(0, 212, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.2);
border-color: var(--accent-cyan, #00d4ff);
}
+158
View File
@@ -0,0 +1,158 @@
/* Drone Intelligence Styles */
/* ── Main visuals panel ── */
.drone-visuals-container {
display: none;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
background: var(--bg-primary);
padding: 0;
box-sizing: border-box;
}
.drone-visuals-header {
flex-shrink: 0;
padding: 10px 16px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.drone-visuals-stats {
display: flex;
gap: 24px;
}
.drone-vsstat {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.drone-vsstat-value {
font-size: 22px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--text-primary);
line-height: 1;
}
.drone-vsstat-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.drone-visuals-body {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
.drone-contact-panel {
width: 300px;
flex-shrink: 0;
overflow-y: auto;
border-right: 1px solid var(--border-color);
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.drone-main-map {
flex: 1;
min-width: 0;
min-height: 400px;
}
.drone-empty-state {
padding: 24px 12px;
text-align: center;
font-size: 11px;
color: var(--text-dim);
line-height: 1.6;
}
.drone-vector-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.drone-vector-pill {
font-size: 10px;
font-family: var(--font-mono);
padding: 3px 8px;
border-radius: 3px;
background: var(--bg-primary);
color: var(--text-dim);
border: 1px solid var(--border-color);
transition: background 0.2s, color 0.2s;
}
.drone-vector-pill.active {
background: color-mix(in srgb, var(--accent-cyan) 15%, transparent);
color: var(--accent-cyan);
border-color: var(--accent-cyan);
}
.drone-contact-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 10px 12px;
margin-bottom: 8px;
cursor: pointer;
transition: border-color 0.15s;
}
.drone-contact-card:hover {
border-color: var(--accent-cyan);
}
.drone-contact-card.high-risk {
border-left: 3px solid var(--accent-red);
}
.drone-contact-card.medium-risk {
border-left: 3px solid var(--accent-yellow);
}
.drone-contact-card.low-risk {
border-left: 3px solid var(--accent-green);
}
.drone-compliance-badge {
font-size: 9px;
font-family: var(--font-mono);
padding: 2px 6px;
border-radius: 2px;
font-weight: 600;
text-transform: uppercase;
}
.drone-compliance-badge.compliant {
background: color-mix(in srgb, var(--accent-green) 20%, transparent);
color: var(--accent-green);
}
.drone-compliance-badge.non-compliant {
background: color-mix(in srgb, var(--accent-red) 20%, transparent);
color: var(--accent-red);
}
.drone-marker-high-risk {
animation: dsc-distress-pulse 1.5s infinite;
}
@keyframes dsc-distress-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(1.4); }
}
+1 -1
View File
@@ -364,7 +364,7 @@
}
/* Constellation colors */
.gps-const-gps { background-color: #00d4ff; }
.gps-const-gps { background-color: var(--accent-cyan); }
.gps-const-glonass { background-color: #00ff88; }
.gps-const-galileo { background-color: #ff8800; }
.gps-const-beidou { background-color: #ff4466; }
+429
View File
@@ -0,0 +1,429 @@
/* Meshcore mode — scoped styles */
/* Sidebar hiding (same rules as meshtastic.css needed here since
meshtastic.css is only lazily loaded when that mode is visited) */
.main-content.mesh-sidebar-hidden {
display: flex !important;
flex-direction: column !important;
}
.main-content.mesh-sidebar-hidden > .sidebar {
display: none !important;
width: 0 !important;
height: 0 !important;
overflow: hidden !important;
}
.main-content.mesh-sidebar-hidden > .output-panel {
flex: 1 !important;
width: 100% !important;
max-width: 100% !important;
}
/* ── Visuals container (base rules duplicated from meshtastic.css — lazy-load safety) ── */
#meshcoreVisuals {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
padding: 0;
gap: 0;
}
/* meshcoreMode is an empty wrapper kept only for JS active-class toggle */
#meshcoreMode { display: none; }
/* ── Connection strip ── */
.meshcore-strip {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bg-card);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
flex-wrap: wrap;
}
.meshcore-strip-group {
display: flex;
align-items: center;
gap: 6px;
}
.meshcore-strip-divider {
width: 1px;
height: 20px;
background: var(--border-color);
margin: 0 4px;
}
.meshcore-strip-status-text {
font-size: 12px;
color: var(--text-muted);
}
.meshcore-strip-select {
background: var(--bg-input);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 6px;
font-size: 12px;
border-radius: 3px;
}
.meshcore-strip-input {
background: var(--bg-input);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 6px;
font-size: 12px;
border-radius: 3px;
font-family: var(--font-mono);
}
.meshcore-strip-btn {
padding: 4px 10px;
font-size: 12px;
border-radius: 3px;
cursor: pointer;
border: 1px solid var(--border-color);
background: var(--bg-input);
color: var(--text-primary);
transition: background 0.15s;
}
.meshcore-strip-btn:hover { opacity: 0.85; }
.meshcore-strip-btn.connect { background: var(--accent-cyan); color: #000; border-color: var(--accent-cyan); }
.meshcore-strip-btn.disconnect { border-color: #f44336; color: #f44336; }
.meshcore-strip-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* Transport tabs in strip */
.meshcore-transport-tabs {
display: flex;
gap: 2px;
}
.meshcore-transport-tab {
padding: 3px 8px;
font-size: 11px;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 3px;
cursor: pointer;
color: var(--text-muted);
transition: background 0.15s, color 0.15s;
}
.meshcore-transport-tab.active {
background: var(--accent-cyan);
color: #000;
border-color: var(--accent-cyan);
}
/* Strip stats */
.meshcore-strip-stat {
display: flex;
flex-direction: column;
align-items: center;
min-width: 40px;
}
.meshcore-strip-value {
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
font-family: var(--font-mono);
line-height: 1;
}
.meshcore-strip-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
/* ── Status dot ── */
.meshcore-status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.meshcore-status-dot.connected { background: #4caf50; box-shadow: 0 0 5px #4caf50; }
.meshcore-status-dot.connecting { background: #ff9800; animation: meshcore-pulse 1s infinite; }
.meshcore-status-dot.error { background: #f44336; }
@keyframes meshcore-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ── Body (panel + content) ── */
.meshcore-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ── Left contacts/nodes panel ── */
.meshcore-panel {
width: 200px;
min-width: 200px;
background: var(--bg-card);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
}
.meshcore-panel-section {
padding: 10px;
border-bottom: 1px solid var(--border-color);
overflow-y: auto;
}
.meshcore-panel-section--grow {
flex: 1;
}
.meshcore-panel-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin-bottom: 8px;
}
/* ── Node / contact list items ── */
.meshcore-node-item {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 0;
font-size: 12px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
}
.meshcore-node-item:last-child { border-bottom: none; }
.meshcore-node-icon {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent-cyan);
flex-shrink: 0;
}
.meshcore-node-icon.repeater {
border-radius: 0;
clip-path: polygon(50% 0%, 100% 100%, 0% 100%);
background: #ff9800;
}
.meshcore-node-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.meshcore-node-meta { font-size: 10px; color: var(--text-muted); }
.meshcore-empty {
font-size: 11px;
color: var(--text-muted);
}
.meshcore-label {
font-size: 12px;
color: var(--text-muted);
}
/* ── Right content ── */
.meshcore-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
/* ── Tab bar ── */
.meshcore-tabs {
display: flex;
border-bottom: 1px solid var(--border-color);
background: var(--bg-card);
flex-shrink: 0;
}
.meshcore-tab {
padding: 8px 16px;
font-size: 12px;
cursor: pointer;
color: var(--text-muted);
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
.meshcore-tab.active {
color: var(--accent-cyan);
border-bottom-color: var(--accent-cyan);
}
/* ── Tab panels ── */
.meshcore-tab-panel {
display: none;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.meshcore-tab-panel.active { display: flex; }
/* ── Message feed ── */
.meshcore-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.meshcore-message {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 10px;
font-size: 12px;
}
.meshcore-message.pending { opacity: 0.6; border-style: dashed; }
.meshcore-message.direct { border-left: 3px solid var(--accent-cyan); }
.meshcore-message-header {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
font-size: 11px;
color: var(--text-muted);
}
.meshcore-message-sender { color: var(--accent-cyan); font-weight: 600; }
.meshcore-message-text { color: var(--text-primary); }
/* ── Compose bar ── */
.meshcore-compose {
display: flex;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--border-color);
background: var(--bg-card);
flex-shrink: 0;
}
.meshcore-compose-select {
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 6px 8px;
color: var(--text-primary);
font-size: 12px;
width: 140px;
}
.meshcore-compose-input {
flex: 1;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 6px 10px;
color: var(--text-primary);
font-size: 13px;
font-family: var(--font-mono);
}
.meshcore-compose-input:focus {
outline: none;
border-color: var(--accent-cyan);
}
.meshcore-compose-btn {
padding: 6px 16px;
background: var(--accent-cyan);
color: #000;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
/* ── Map tab ── */
#meshcoreTabMap { overflow: hidden; }
#meshcoreMap {
width: 100%;
height: 100%;
min-height: 300px;
}
/* ── Repeaters table ── */
.meshcore-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.meshcore-table th {
text-align: left;
padding: 6px 10px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
border-bottom: 1px solid var(--border-color);
}
.meshcore-table td {
padding: 6px 10px;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
}
/* ── Traceroute modal ── */
.meshcore-traceroute-hops {
display: flex;
align-items: center;
flex-wrap: wrap;
padding: 16px 0;
}
.meshcore-hop {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.meshcore-hop-node {
background: var(--bg-input);
border: 1px solid var(--accent-cyan);
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
font-family: var(--font-mono);
}
.meshcore-hop-arrow {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 6px;
font-size: 10px;
color: var(--text-muted);
}
+4 -4
View File
@@ -650,9 +650,9 @@
}
.mesh-badge-primary {
background: rgba(74, 158, 255, 0.15);
background: rgba(var(--accent-cyan-rgb), 0.15);
color: var(--accent-cyan);
border: 1px solid rgba(74, 158, 255, 0.3);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.3);
}
.mesh-badge-secondary {
@@ -1349,7 +1349,7 @@
}
.mesh-traceroute-snr.snr-ok {
background: rgba(74, 158, 255, 0.15);
background: rgba(var(--accent-cyan-rgb), 0.15);
color: var(--accent-cyan);
}
@@ -1572,7 +1572,7 @@
}
.mesh-network-neighbor-snr.snr-ok {
background: rgba(74, 158, 255, 0.15);
background: rgba(var(--accent-cyan-rgb), 0.15);
color: var(--accent-cyan);
}
+23
View File
@@ -471,3 +471,26 @@
height: 40px;
}
}
html[data-ui-tier="enhanced"] .meteor-visuals-container {
--ms-border: rgba(46, 125, 138, 0.22);
--ms-surface: linear-gradient(180deg, rgba(2, 6, 6, 0.97) 0%, rgba(1, 3, 3, 0.98) 100%);
--ms-accent: #2e7d8a;
--ms-accent-dim: rgba(46, 125, 138, 0.12);
background: radial-gradient(circle at 14% -18%, rgba(46, 125, 138, 0.10) 0%, rgba(46, 125, 138, 0) 38%),
radial-gradient(circle at 86% -26%, rgba(46, 125, 138, 0.07) 0%, rgba(46, 125, 138, 0) 36%),
#000202;
}
html[data-ui-tier="enhanced"] .ms-headline,
html[data-ui-tier="enhanced"] .ms-events-panel {
background: rgba(2, 6, 6, 0.9);
}
html[data-ui-tier="enhanced"] .ms-events-table th {
background: rgba(2, 6, 6, 0.95);
}
html[data-ui-tier="enhanced"] .ms-events-table tr:hover td {
background: rgba(46, 125, 138, 0.04);
}
+14
View File
@@ -199,3 +199,17 @@
align-items: stretch;
}
}
html[data-ui-tier="enhanced"] .morse-raw-panel {
border-color: rgba(46, 125, 138, 0.18);
background: rgba(1, 4, 4, 0.9);
}
html[data-ui-tier="enhanced"] .morse-raw-text {
color: rgba(70, 180, 200, 0.90);
}
html[data-ui-tier="enhanced"] .morse-metrics-panel span {
border-color: rgba(46, 125, 138, 0.18);
background: rgba(1, 4, 4, 0.88);
}
+4 -4
View File
@@ -160,14 +160,14 @@
flex-direction: column;
align-items: center;
padding: 4px 10px;
background: rgba(0, 229, 255, 0.05);
border: 1px solid rgba(0, 229, 255, 0.15);
background: rgba(var(--accent-cyan-rgb), 0.05);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.15);
border-radius: 4px;
min-width: 55px;
}
.radiosonde-strip .strip-stat:hover {
background: rgba(0, 229, 255, 0.1);
border-color: rgba(0, 229, 255, 0.3);
background: rgba(var(--accent-cyan-rgb), 0.1);
border-color: rgba(var(--accent-cyan-rgb), 0.3);
}
.radiosonde-strip .strip-value {
font-family: var(--font-mono);
+2 -2
View File
@@ -128,9 +128,9 @@
}
.spy-badge-number {
background: rgba(74, 158, 255, 0.15);
background: rgba(var(--accent-cyan-rgb), 0.15);
color: var(--accent-cyan);
border: 1px solid rgba(74, 158, 255, 0.3);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.3);
}
.spy-badge-diplomatic {
+1 -1
View File
@@ -342,7 +342,7 @@
.sstv-general-image-card:hover {
border-color: var(--accent-cyan);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
box-shadow: 0 4px 12px rgba(var(--accent-cyan-rgb), 0.2);
}
.sstv-general-image-card-inner {
+2 -2
View File
@@ -401,7 +401,7 @@
.sstv-image-card:hover {
border-color: var(--accent-cyan);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
box-shadow: 0 4px 12px rgba(var(--accent-cyan-rgb), 0.2);
}
.sstv-image-card-inner {
@@ -688,7 +688,7 @@
font-weight: 700;
color: var(--accent-cyan);
letter-spacing: 2px;
text-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
text-shadow: 0 0 20px rgba(var(--accent-cyan-rgb), 0.3);
}
.sstv-countdown-value.imminent {
+69 -69
View File
@@ -93,9 +93,9 @@
}
.subghz-preset-btn:hover {
background: var(--accent-cyan, #00d4ff);
background: var(--accent-cyan, var(--accent-cyan));
color: var(--text-inverse);
border-color: var(--accent-cyan, #00d4ff);
border-color: var(--accent-cyan, var(--accent-cyan));
}
/* Tab navigation for RX / Decode / Sweep */
@@ -126,8 +126,8 @@
}
.subghz-tab.active {
color: var(--accent-cyan, #00d4ff);
border-bottom-color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, var(--accent-cyan));
border-bottom-color: var(--accent-cyan, var(--accent-cyan));
}
.subghz-tab-content {
@@ -225,7 +225,7 @@
}
.subghz-status-dot.decode {
background: #00d4ff;
background: var(--accent-cyan);
animation: subghz-pulse 0.8s ease-in-out infinite;
}
@@ -250,7 +250,7 @@
}
.subghz-status-timer {
color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, var(--accent-cyan));
}
/* Control buttons */
@@ -296,13 +296,13 @@
.subghz-btn:hover {
background: var(--bg-tertiary, #1a1f2e);
border-color: var(--accent-cyan, #00d4ff);
border-color: var(--accent-cyan, var(--accent-cyan));
}
.subghz-btn.active {
background: rgba(0, 212, 255, 0.1);
border-color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, #00d4ff);
background: rgba(var(--accent-cyan-rgb), 0.1);
border-color: var(--accent-cyan, var(--accent-cyan));
color: var(--accent-cyan, var(--accent-cyan));
}
.subghz-btn.start {
@@ -384,9 +384,9 @@
}
.subghz-capture-card.selected {
border-color: rgba(0, 212, 255, 0.85);
box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.3);
background: rgba(0, 212, 255, 0.06);
border-color: rgba(var(--accent-cyan-rgb), 0.85);
box-shadow: 0 0 0 1px rgba(var(--accent-cyan-rgb), 0.3);
background: rgba(var(--accent-cyan-rgb), 0.06);
}
.subghz-capture-header {
@@ -455,9 +455,9 @@
}
.subghz-capture-tag.auto {
border-color: rgba(0, 212, 255, 0.55);
color: #00d4ff;
background: rgba(0, 212, 255, 0.12);
border-color: rgba(var(--accent-cyan-rgb), 0.55);
color: var(--accent-cyan);
background: rgba(var(--accent-cyan-rgb), 0.12);
}
.subghz-capture-tag.hint {
@@ -473,7 +473,7 @@
}
.subghz-capture-freq {
color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, var(--accent-cyan));
font-weight: 600;
}
@@ -526,8 +526,8 @@
}
.subghz-capture-actions button.trim-btn:hover {
color: #00d4ff;
border-color: #00d4ff;
color: var(--accent-cyan);
border-color: var(--accent-cyan);
}
.subghz-capture-actions button.delete-btn:hover {
@@ -536,13 +536,13 @@
}
.subghz-capture-actions button.select-btn {
border-color: rgba(0, 212, 255, 0.5);
color: #00d4ff;
border-color: rgba(var(--accent-cyan-rgb), 0.5);
color: var(--accent-cyan);
}
.subghz-capture-actions button.select-btn.selected {
border-color: rgba(0, 212, 255, 0.9);
background: rgba(0, 212, 255, 0.18);
border-color: rgba(var(--accent-cyan-rgb), 0.9);
background: rgba(var(--accent-cyan-rgb), 0.18);
color: #7beeff;
}
@@ -622,7 +622,7 @@
}
.subghz-decode-model {
color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, var(--accent-cyan));
font-weight: 600;
}
@@ -693,7 +693,7 @@
}
.subghz-tx-modal .tx-freq {
color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, var(--accent-cyan));
font-weight: 600;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
@@ -754,7 +754,7 @@
margin-top: 8px !important;
margin-bottom: 0 !important;
font-size: 11px !important;
color: var(--accent-cyan, #00d4ff) !important;
color: var(--accent-cyan, var(--accent-cyan)) !important;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
@@ -781,14 +781,14 @@
height: 26px;
border: 1px solid var(--border-color, #2a3040);
border-radius: 4px;
background: linear-gradient(90deg, rgba(0, 212, 255, 0.07), rgba(255, 170, 0, 0.07));
background: linear-gradient(90deg, rgba(var(--accent-cyan-rgb), 0.07), rgba(255, 170, 0, 0.07));
margin-bottom: 8px;
overflow: hidden;
}
.subghz-tx-burst-timeline.dragging {
border-color: rgba(0, 212, 255, 0.65);
box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.25) inset;
border-color: rgba(var(--accent-cyan-rgb), 0.65);
box-shadow: 0 0 0 1px rgba(var(--accent-cyan-rgb), 0.25) inset;
}
.subghz-tx-burst-selection {
@@ -796,8 +796,8 @@
top: 3px;
bottom: 3px;
border-radius: 3px;
border: 1px solid rgba(0, 212, 255, 0.95);
background: rgba(0, 212, 255, 0.22);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.95);
background: rgba(var(--accent-cyan-rgb), 0.22);
pointer-events: none;
display: none;
z-index: 2;
@@ -807,7 +807,7 @@
margin: 0 0 8px 0;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, var(--accent-cyan));
}
.subghz-tx-burst-marker {
@@ -823,8 +823,8 @@
}
.subghz-tx-burst-marker:hover {
background: rgba(0, 212, 255, 0.85);
border-color: rgba(0, 212, 255, 1);
background: rgba(var(--accent-cyan-rgb), 0.85);
border-color: rgba(var(--accent-cyan-rgb), 1);
}
.subghz-tx-burst-list {
@@ -861,17 +861,17 @@
.subghz-tx-burst-item button {
padding: 2px 8px;
border: 1px solid rgba(0, 212, 255, 0.5);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.5);
border-radius: 3px;
background: transparent;
color: #00d4ff;
color: var(--accent-cyan);
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
cursor: pointer;
}
.subghz-tx-burst-item button:hover {
background: rgba(0, 212, 255, 0.12);
background: rgba(var(--accent-cyan-rgb), 0.12);
}
.subghz-tx-modal-actions {
@@ -901,13 +901,13 @@
}
.subghz-tx-trim-btn {
background: rgba(0, 212, 255, 0.14);
color: #00d4ff;
border-color: rgba(0, 212, 255, 0.55) !important;
background: rgba(var(--accent-cyan-rgb), 0.14);
color: var(--accent-cyan);
border-color: rgba(var(--accent-cyan-rgb), 0.55) !important;
}
.subghz-tx-trim-btn:hover {
background: rgba(0, 212, 255, 0.26);
background: rgba(var(--accent-cyan-rgb), 0.26);
}
.subghz-tx-cancel-btn {
@@ -952,7 +952,7 @@
}
.subghz-sweep-tooltip .tip-freq {
color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, var(--accent-cyan));
}
.subghz-sweep-tooltip .tip-power {
@@ -1043,9 +1043,9 @@
}
.subghz-action-btn.decode:hover {
background: rgba(0, 212, 255, 0.12);
border-color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, #00d4ff);
background: rgba(var(--accent-cyan-rgb), 0.12);
border-color: var(--accent-cyan, var(--accent-cyan));
color: var(--accent-cyan, var(--accent-cyan));
}
.subghz-action-btn.capture:hover {
@@ -1092,7 +1092,7 @@
}
.subghz-peak-item .peak-freq {
color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, var(--accent-cyan));
}
.subghz-peak-item .peak-power {
@@ -1148,7 +1148,7 @@
}
.subghz-strip-dot.rx { background: var(--neon-green); }
.subghz-strip-dot.decode { background: #00d4ff; }
.subghz-strip-dot.decode { background: var(--accent-cyan); }
.subghz-strip-dot.tx { background: #ff4444; }
.subghz-strip-dot.sweep { background: var(--neon-orange); }
@@ -1169,7 +1169,7 @@
color: var(--text-primary, #e0e0e0);
}
.subghz-strip-value.accent-cyan { color: var(--accent-cyan, #00d4ff); }
.subghz-strip-value.accent-cyan { color: var(--accent-cyan, var(--accent-cyan)); }
.subghz-strip-value.accent-green { color: var(--neon-green); }
.subghz-strip-value.accent-orange { color: var(--neon-orange); }
@@ -1181,7 +1181,7 @@
}
.subghz-strip-timer {
color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, var(--accent-cyan));
font-weight: 600;
min-width: 40px;
}
@@ -1274,8 +1274,8 @@
}
.subghz-phase-step.active {
color: var(--accent-cyan, #00d4ff);
text-shadow: 0 0 6px rgba(0, 212, 255, 0.3);
color: var(--accent-cyan, var(--accent-cyan));
text-shadow: 0 0 6px rgba(var(--accent-cyan-rgb), 0.3);
}
.subghz-phase-step.completed {
@@ -1328,13 +1328,13 @@
}
.subghz-burst-indicator.recent {
border-color: rgba(0, 212, 255, 0.45);
color: #00d4ff;
background: rgba(0, 212, 255, 0.1);
border-color: rgba(var(--accent-cyan-rgb), 0.45);
color: var(--accent-cyan);
background: rgba(var(--accent-cyan-rgb), 0.1);
}
.subghz-burst-indicator.recent .subghz-burst-dot {
background: #00d4ff;
background: var(--accent-cyan);
}
.subghz-console-toggle {
@@ -1382,7 +1382,7 @@
}
.subghz-log-msg { color: var(--text-secondary, #999); }
.subghz-log-msg.info { color: var(--accent-cyan, #00d4ff); }
.subghz-log-msg.info { color: var(--accent-cyan, var(--accent-cyan)); }
.subghz-log-msg.success { color: var(--neon-green); }
.subghz-log-msg.warn { color: var(--neon-orange); }
.subghz-log-msg.error { color: var(--accent-red, #ff4444); }
@@ -1405,7 +1405,7 @@
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 20px;
font-weight: 700;
color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, var(--accent-cyan));
letter-spacing: 1px;
}
@@ -1445,9 +1445,9 @@
transform: translateY(0);
}
.subghz-hub-card--cyan { border-color: rgba(0, 212, 255, 0.2); }
.subghz-hub-card--cyan:hover { border-color: var(--accent-cyan, #00d4ff); background: rgba(0, 212, 255, 0.05); }
.subghz-hub-card--cyan .subghz-hub-icon { color: var(--accent-cyan, #00d4ff); }
.subghz-hub-card--cyan { border-color: rgba(var(--accent-cyan-rgb), 0.2); }
.subghz-hub-card--cyan:hover { border-color: var(--accent-cyan, var(--accent-cyan)); background: rgba(var(--accent-cyan-rgb), 0.05); }
.subghz-hub-card--cyan .subghz-hub-icon { color: var(--accent-cyan, var(--accent-cyan)); }
.subghz-hub-card--green { border-color: rgba(0, 255, 136, 0.2); }
.subghz-hub-card--green:hover { border-color: var(--neon-green); background: rgba(0, 255, 136, 0.05); }
@@ -1528,7 +1528,7 @@
.subghz-saved-selection-count {
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, var(--accent-cyan));
margin-right: 4px;
}
@@ -1545,8 +1545,8 @@
}
.subghz-op-back-btn:hover {
border-color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, #00d4ff);
border-color: var(--accent-cyan, var(--accent-cyan));
color: var(--accent-cyan, var(--accent-cyan));
}
.subghz-op-panel-title {
@@ -1667,7 +1667,7 @@
color: var(--text-primary, #e0e0e0);
}
.subghz-rx-info-value.accent-cyan { color: var(--accent-cyan, #00d4ff); }
.subghz-rx-info-value.accent-cyan { color: var(--accent-cyan, var(--accent-cyan)); }
.subghz-rx-level-wrapper {
display: flex;
@@ -1735,9 +1735,9 @@
}
.subghz-rx-burst-pill.recent {
color: #00d4ff;
border-color: rgba(0, 212, 255, 0.65);
background: rgba(0, 212, 255, 0.12);
color: var(--accent-cyan);
border-color: rgba(var(--accent-cyan-rgb), 0.65);
background: rgba(var(--accent-cyan-rgb), 0.12);
}
.subghz-rx-level-label {
@@ -1861,8 +1861,8 @@
}
.subghz-wf-pause-btn:hover {
border-color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, #00d4ff);
border-color: var(--accent-cyan, var(--accent-cyan));
color: var(--accent-cyan, var(--accent-cyan));
}
.subghz-wf-pause-btn.paused {
+2 -2
View File
@@ -17,7 +17,7 @@
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--accent-cyan, #00d4ff);
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
border-bottom: 1px solid rgba(var(--accent-cyan-rgb), 0.2);
padding-bottom: 6px;
margin-top: 8px;
}
@@ -221,7 +221,7 @@
fill: none;
stroke: var(--accent-cyan, #00d4ff);
stroke-width: 1.5;
filter: drop-shadow(0 0 2px rgba(0, 212, 255, 0.4));
filter: drop-shadow(0 0 2px rgba(var(--accent-cyan-rgb), 0.4));
}
.sys-sparkline-area {
+13 -13
View File
@@ -102,7 +102,7 @@
transition: background 0.2s;
}
.tscm-device-item:hover {
background: rgba(74,158,255,0.1);
background: rgba(var(--accent-cyan-rgb),0.1);
}
.tscm-device-item.new {
border-left-color: var(--severity-high);
@@ -212,9 +212,9 @@
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
background: rgba(74, 158, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.2);
color: var(--accent-cyan);
border: 1px solid rgba(74, 158, 255, 0.4);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.4);
text-transform: uppercase;
letter-spacing: 0.4px;
}
@@ -544,8 +544,8 @@
padding: 12px 16px;
font-size: 10px;
color: var(--text-secondary);
background: rgba(74, 158, 255, 0.1);
border-top: 1px solid rgba(74, 158, 255, 0.3);
background: rgba(var(--accent-cyan-rgb), 0.1);
border-top: 1px solid rgba(var(--accent-cyan-rgb), 0.3);
}
.tscm-threat-action {
margin-top: 6px;
@@ -605,7 +605,7 @@
.protocol-badge {
font-size: 9px;
padding: 2px 6px;
background: rgba(74, 158, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.2);
color: var(--accent-cyan);
border-radius: 3px;
text-transform: uppercase;
@@ -709,7 +709,7 @@
}
.scanner-track {
fill: none;
stroke: rgba(74,158,255,0.1);
stroke: rgba(var(--accent-cyan-rgb),0.1);
stroke-width: 4;
}
.scanner-progress {
@@ -1081,7 +1081,7 @@
transition: background 0.2s;
}
.case-item:hover {
background: rgba(74, 158, 255, 0.1);
background: rgba(var(--accent-cyan-rgb), 0.1);
}
.case-item.priority-high { border-left-color: var(--accent-red); }
.case-item.priority-normal { border-left-color: var(--accent-cyan); }
@@ -1359,7 +1359,7 @@
.known-device-type {
font-size: 9px;
padding: 2px 6px;
background: rgba(74, 158, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.2);
color: var(--accent-cyan);
border-radius: 3px;
margin-left: 8px;
@@ -1557,8 +1557,8 @@
gap: 8px;
padding: 8px 10px;
margin-bottom: 10px;
background: rgba(74, 158, 255, 0.12);
border: 1px solid rgba(74, 158, 255, 0.3);
background: rgba(var(--accent-cyan-rgb), 0.12);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.3);
border-radius: 6px;
font-size: 11px;
}
@@ -1569,9 +1569,9 @@
margin-left: auto;
font-size: 9px;
padding: 2px 6px;
background: rgba(74, 158, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.2);
color: #9ed0ff;
border: 1px solid rgba(74, 158, 255, 0.4);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.4);
border-radius: 3px;
cursor: pointer;
}
+3 -3
View File
@@ -12,8 +12,8 @@
background: var(--accent-red) !important;
}
@keyframes vdl2-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(74, 158, 255, 0.3); }
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 */
@@ -23,7 +23,7 @@
animation: vdl2FadeIn 0.3s ease;
}
.vdl2-msg:hover {
background: rgba(74, 158, 255, 0.05);
background: rgba(var(--accent-cyan-rgb), 0.05);
}
@keyframes vdl2FadeIn {
from { opacity: 0; transform: translateY(-3px); }
+152 -16
View File
@@ -39,8 +39,8 @@
.wf-headline-tag {
border-radius: 999px;
padding: 1px 8px;
border: 1px solid rgba(74, 163, 255, 0.45);
background: rgba(74, 163, 255, 0.13);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.45);
background: rgba(var(--accent-cyan-rgb), 0.13);
color: #8ec5ff;
font-size: 10px;
font-family: var(--font-mono, monospace);
@@ -404,12 +404,12 @@
}
.wf-step-btn:hover {
background: rgba(74, 163, 255, 0.17);
border-color: rgba(74, 163, 255, 0.45);
background: rgba(var(--accent-cyan-rgb), 0.17);
border-color: rgba(var(--accent-cyan-rgb), 0.45);
}
.wf-step-btn:active {
background: rgba(74, 163, 255, 0.28);
background: rgba(var(--accent-cyan-rgb), 0.28);
}
.wf-zoom-btn {
@@ -422,7 +422,7 @@
align-items: center;
gap: 5px;
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(74, 163, 255, 0.28);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.28);
border-radius: 5px;
padding: 3px 8px;
flex-shrink: 0;
@@ -652,7 +652,7 @@
.wf-resize-handle:hover,
.wf-resize-handle.dragging {
background: rgba(74, 163, 255, 0.14);
background: rgba(var(--accent-cyan-rgb), 0.14);
}
.wf-resize-grip {
@@ -665,7 +665,7 @@
.wf-resize-handle:hover .wf-resize-grip,
.wf-resize-handle.dragging .wf-resize-grip {
background: rgba(74, 163, 255, 0.6);
background: rgba(var(--accent-cyan-rgb), 0.6);
}
/* Waterfall canvas */
@@ -705,7 +705,7 @@
.wf-tune-line {
left: calc(50% - 0.5px);
background: rgba(130, 220, 255, 0.75);
box-shadow: 0 0 8px rgba(74, 163, 255, 0.4);
box-shadow: 0 0 8px rgba(var(--accent-cyan-rgb), 0.4);
opacity: 0;
transition: opacity 140ms ease;
}
@@ -760,7 +760,7 @@
display: none;
z-index: 10;
white-space: nowrap;
border: 1px solid rgba(74, 163, 255, 0.22);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.22);
}
@media (max-width: 1023px) {
@@ -904,13 +904,13 @@
.wf-side-box {
margin-top: 8px;
padding: 8px;
border: 1px solid rgba(74, 163, 255, 0.2);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.2);
border-radius: 6px;
background: rgba(0, 0, 0, 0.25);
}
.wf-side-box-muted {
border-color: rgba(74, 163, 255, 0.14);
border-color: rgba(var(--accent-cyan-rgb), 0.14);
background: rgba(0, 0, 0, 0.2);
}
@@ -984,7 +984,7 @@
align-items: center;
gap: 6px;
background: rgba(0, 0, 0, 0.24);
border: 1px solid rgba(74, 163, 255, 0.16);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.16);
border-radius: 5px;
padding: 5px 7px;
min-width: 0;
@@ -1067,7 +1067,7 @@
.wf-scan-metric-card {
background: rgba(0, 0, 0, 0.24);
border: 1px solid rgba(74, 163, 255, 0.18);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.18);
border-radius: 6px;
padding: 7px 6px;
text-align: center;
@@ -1092,7 +1092,7 @@
margin-top: 8px;
max-height: 145px;
overflow: auto;
border: 1px solid rgba(74, 163, 255, 0.16);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.16);
border-radius: 6px;
}
@@ -1146,7 +1146,7 @@
margin-top: 8px;
max-height: 130px;
overflow-y: auto;
border: 1px solid rgba(74, 163, 255, 0.16);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.16);
border-radius: 6px;
background: rgba(0, 0, 0, 0.2);
padding: 6px;
@@ -1180,3 +1180,139 @@
font-family: var(--font-mono, monospace);
font-size: 9px;
}
/* ---- Enhanced tier: replace blue palette with signals teal ---- */
html[data-ui-tier="enhanced"] .wf-container {
--wf-border: rgba(46, 125, 138, 0.22);
--wf-surface: linear-gradient(180deg, rgba(2, 6, 6, 0.97) 0%, rgba(1, 3, 3, 0.98) 100%);
background: radial-gradient(circle at 14% -18%, rgba(46, 125, 138, 0.08) 0%, rgba(46, 125, 138, 0) 38%),
radial-gradient(circle at 86% -26%, rgba(46, 125, 138, 0.05) 0%, rgba(46, 125, 138, 0) 36%),
#000202;
}
html[data-ui-tier="enhanced"] .wf-headline {
background: rgba(2, 6, 6, 0.86);
}
html[data-ui-tier="enhanced"] .wf-headline-tag {
color: rgba(70, 185, 200, 0.90);
}
html[data-ui-tier="enhanced"] .wf-rx-vfo {
border-color: rgba(46, 125, 138, 0.25);
background: linear-gradient(180deg, rgba(3, 8, 8, 0.92) 0%, rgba(1, 4, 4, 0.95) 100%);
}
html[data-ui-tier="enhanced"] .wf-rx-vfo-status {
color: rgba(65, 175, 192, 0.88);
}
html[data-ui-tier="enhanced"] .wf-rx-vfo-readout {
color: rgba(80, 190, 205, 0.92);
}
html[data-ui-tier="enhanced"] #wfRxFreqReadout {
text-shadow: 0 0 16px rgba(46, 125, 138, 0.25);
}
html[data-ui-tier="enhanced"] .wf-rx-modebank {
border-color: rgba(46, 125, 138, 0.22);
background: rgba(1, 4, 4, 0.86);
}
html[data-ui-tier="enhanced"] .wf-mode-btn {
border-color: rgba(46, 125, 138, 0.24);
background: linear-gradient(180deg, rgba(4, 8, 8, 0.95) 0%, rgba(2, 5, 5, 0.95) 100%);
color: rgba(65, 175, 192, 0.90);
}
html[data-ui-tier="enhanced"] .wf-mode-btn:hover {
border-color: rgba(46, 125, 138, 0.48);
}
html[data-ui-tier="enhanced"] .wf-mode-btn.is-active,
html[data-ui-tier="enhanced"] .wf-mode-btn.active {
border-color: rgba(46, 125, 138, 0.62);
background: linear-gradient(180deg, rgba(6, 16, 18, 0.92) 0%, rgba(4, 12, 14, 0.95) 100%);
color: rgba(100, 210, 222, 0.96);
box-shadow: 0 0 14px rgba(46, 125, 138, 0.22);
}
html[data-ui-tier="enhanced"] .wf-rx-levels,
html[data-ui-tier="enhanced"] .wf-rx-meter-wrap,
html[data-ui-tier="enhanced"] .wf-rx-actions {
border-color: rgba(46, 125, 138, 0.20);
background: rgba(1, 4, 4, 0.85);
}
html[data-ui-tier="enhanced"] .wf-monitor-select {
border-color: rgba(46, 125, 138, 0.25);
background: rgba(1, 3, 3, 0.8);
}
html[data-ui-tier="enhanced"] .wf-rx-smeter-fill {
box-shadow: 0 0 10px rgba(46, 125, 138, 0.22);
}
html[data-ui-tier="enhanced"] .wf-monitor-btn-secondary {
border-color: rgba(46, 125, 138, 0.45);
background: linear-gradient(180deg, rgba(4, 12, 14, 0.95) 0%, rgba(2, 8, 10, 0.95) 100%);
color: rgba(80, 190, 205, 0.90);
}
html[data-ui-tier="enhanced"] .wf-freq-bar {
background: rgba(2, 6, 6, 0.78);
}
html[data-ui-tier="enhanced"] .wf-spectrum-canvas-wrap {
background: radial-gradient(circle at 50% -120%, rgba(46, 125, 138, 0.06) 0%, rgba(46, 125, 138, 0) 65%);
}
html[data-ui-tier="enhanced"] .wf-band-strip {
background: linear-gradient(180deg, rgba(2, 6, 6, 0.96) 0%, rgba(1, 3, 3, 0.98) 100%);
}
html[data-ui-tier="enhanced"] .wf-band-block {
border-color: rgba(46, 125, 138, 0.42);
color: rgba(80, 190, 205, 0.92);
}
html[data-ui-tier="enhanced"] .wf-band-edge {
color: rgba(65, 175, 192, 0.88);
}
html[data-ui-tier="enhanced"] .wf-band-marker::before {
background: rgba(46, 125, 138, 0.58);
box-shadow: 0 0 5px rgba(46, 125, 138, 0.30);
}
html[data-ui-tier="enhanced"] .wf-band-marker-label {
border-color: rgba(46, 125, 138, 0.48);
background: rgba(2, 5, 5, 0.95);
color: rgba(80, 190, 205, 0.90);
}
html[data-ui-tier="enhanced"] .wf-tune-line {
background: rgba(46, 125, 138, 0.72);
}
html[data-ui-tier="enhanced"] .wf-freq-axis {
background: rgba(2, 6, 6, 0.86);
}
html[data-ui-tier="enhanced"] .wf-side .section.wf-side-hero {
background: linear-gradient(180deg, rgba(3, 8, 8, 0.95) 0%, rgba(1, 4, 4, 0.97) 100%);
border-color: rgba(46, 125, 138, 0.30);
box-shadow: 0 8px 24px rgba(0, 8, 10, 0.30), inset 0 0 0 1px rgba(255, 255, 255, 0.03);
}
html[data-ui-tier="enhanced"] .wf-side-chip {
color: rgba(65, 175, 192, 0.88);
border-color: rgba(46, 125, 138, 0.32);
background: rgba(6, 16, 18, 0.30);
}
html[data-ui-tier="enhanced"] .wf-side-stat {
border-color: rgba(46, 125, 138, 0.20);
}
+225 -8
View File
@@ -107,6 +107,23 @@
color: var(--accent-cyan, #00d4ff);
}
/* ===== Timezone Select ===== */
.wxsat-tz-select {
padding: 3px 6px;
background: var(--bg-primary, #0d1117);
border: 1px solid var(--border-color, #2a3040);
border-radius: 3px;
color: var(--text-primary, #e0e0e0);
font-size: 11px;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
cursor: pointer;
}
.wxsat-tz-select:focus {
border-color: var(--accent-cyan, #00d4ff);
outline: none;
}
/* ===== Auto-Schedule Toggle ===== */
.wxsat-schedule-toggle {
display: flex;
@@ -291,7 +308,7 @@
opacity: 1;
}
.wxsat-timeline-pass.apt { background: rgba(0, 212, 255, 0.6); }
.wxsat-timeline-pass.apt { background: rgba(var(--accent-cyan-rgb), 0.6); }
.wxsat-timeline-pass.lrpt { background: rgba(0, 255, 136, 0.6); }
.wxsat-timeline-pass.scheduled { border: 1px solid var(--accent-yellow); }
@@ -317,6 +334,161 @@
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
/* ===== Pass Analysis Bar ===== */
.wxsat-analysis-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 6px 16px;
background: var(--bg-tertiary, #1a1f2e);
border-bottom: 1px solid var(--border-color, #2a3040);
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.wxsat-analysis-stats {
display: flex;
gap: 16px;
}
.wxsat-analysis-stat {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
}
.wxsat-analysis-value {
font-weight: 600;
color: var(--text-primary, #e0e0e0);
}
.wxsat-analysis-value.excellent { color: var(--neon-green); }
.wxsat-analysis-value.good { color: var(--accent-cyan); }
.wxsat-analysis-value.fair { color: var(--accent-yellow); }
.wxsat-analysis-label {
font-size: 10px;
color: var(--text-dim, #666);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.wxsat-analysis-best {
font-size: 11px;
color: var(--accent-cyan, #00d4ff);
white-space: nowrap;
}
/* ===== Best Pass Badge ===== */
.wxsat-pass-best-badge {
display: inline-block;
font-size: 8px;
padding: 1px 5px;
border-radius: 2px;
background: rgba(0, 255, 136, 0.15);
color: var(--neon-green);
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
margin-left: 6px;
}
/* ===== Pass Direction ===== */
.wxsat-pass-direction {
font-size: 10px;
color: var(--text-dim, #666);
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
margin-top: 4px;
}
.wxsat-pass-direction .wxsat-dir-arrow {
color: var(--accent-cyan, #00d4ff);
margin: 0 2px;
}
/* ===== Pass Geometry Detail ===== */
.wxsat-pass-geometry {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 14px;
background: var(--bg-primary, #0d1117);
border-bottom: 1px solid var(--border-color, #2a3040);
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.wxsat-geom-event {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 60px;
}
.wxsat-geom-event.wxsat-geom-tca {
color: var(--neon-green);
}
.wxsat-geom-label {
font-size: 9px;
font-weight: 600;
color: var(--text-dim, #666);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wxsat-geom-tca .wxsat-geom-label {
color: var(--neon-green);
}
.wxsat-geom-time {
font-size: 12px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
}
.wxsat-geom-tca .wxsat-geom-time {
color: var(--neon-green);
}
.wxsat-geom-az {
font-size: 10px;
color: var(--text-dim, #666);
}
.wxsat-geom-arrow {
font-size: 14px;
color: var(--text-dim, #444);
}
.wxsat-geom-meta {
font-size: 10px;
color: var(--text-dim, #666);
margin-left: 8px;
padding-left: 8px;
border-left: 1px solid var(--border-color, #2a3040);
white-space: nowrap;
}
/* ===== Countdown Pulse Animation ===== */
.wxsat-countdown-box.imminent .wxsat-cd-value {
animation: wxsat-count-pulse 1s ease-in-out infinite;
color: var(--accent-yellow);
}
.wxsat-countdown-box.active .wxsat-cd-value {
animation: wxsat-count-pulse 1s ease-in-out infinite;
color: var(--neon-green);
}
@keyframes wxsat-count-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.15); opacity: 0.8; }
}
/* ===== Pass Predictions Panel ===== */
.wxsat-passes-panel {
flex: 0 0 280px;
@@ -413,7 +585,7 @@
}
.wxsat-pass-mode.apt {
background: rgba(0, 212, 255, 0.15);
background: rgba(var(--accent-cyan-rgb), 0.15);
color: var(--accent-cyan);
}
@@ -454,7 +626,7 @@
}
.wxsat-pass-quality.good {
background: rgba(0, 212, 255, 0.15);
background: rgba(var(--accent-cyan-rgb), 0.15);
color: var(--accent-cyan);
}
@@ -587,16 +759,16 @@
.wxsat-map-tooltip {
background: rgba(5, 15, 32, 0.92);
border: 1px solid rgba(102, 229, 255, 0.65);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.65);
border-radius: 4px;
color: #8fe8ff;
color: var(--accent-cyan);
box-shadow: 0 0 12px rgba(0, 210, 255, 0.24);
font-size: 10px;
letter-spacing: 0.25px;
}
.wxsat-map-tooltip.leaflet-tooltip-top:before {
border-top-color: rgba(102, 229, 255, 0.65);
border-top-color: rgba(var(--accent-cyan-rgb), 0.65);
}
/* ===== Image Gallery Panel ===== */
@@ -1049,8 +1221,8 @@
.wxsat-phase-step.completed {
color: var(--accent-cyan, #00d4ff);
border-color: rgba(0, 212, 255, 0.3);
background: rgba(0, 212, 255, 0.05);
border-color: rgba(var(--accent-cyan-rgb), 0.3);
background: rgba(var(--accent-cyan-rgb), 0.05);
opacity: 0.7;
}
@@ -1066,6 +1238,51 @@
color: var(--text-dim, #444);
}
/* Console filter buttons */
.wxsat-console-filters {
display: flex;
gap: 3px;
margin-left: auto;
margin-right: 8px;
}
.wxsat-console-filter {
font-size: 9px;
padding: 2px 6px;
border: 1px solid var(--border-color, #2a3040);
border-radius: 3px;
background: transparent;
color: var(--text-dim, #555);
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
cursor: pointer;
transition: all 0.2s;
letter-spacing: 0.3px;
}
.wxsat-console-filter:hover {
border-color: var(--accent-cyan, #00d4ff);
color: var(--text-secondary, #999);
}
.wxsat-console-filter.active {
border-color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, #00d4ff);
background: rgba(var(--accent-cyan-rgb), 0.08);
}
.wxsat-console-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
/* Console entry timestamps */
.wxsat-console-ts {
color: var(--text-dim, #444);
margin-right: 6px;
font-size: 9px;
}
#wxsatConsoleToggle {
font-size: 10px;
width: 28px;
File diff suppressed because it is too large Load Diff
+68 -9
View File
@@ -266,7 +266,7 @@
}
.toggle-switch input:focus + .toggle-slider {
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3);
box-shadow: 0 0 0 2px rgba(var(--accent-cyan-rgb), 0.3);
}
/* Select Dropdown */
@@ -461,8 +461,8 @@
/* Info Callout */
.settings-info {
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.2);
background: rgba(var(--accent-cyan-rgb), 0.1);
border: 1px solid rgba(var(--accent-cyan-rgb), 0.2);
border-radius: 6px;
padding: 12px;
margin-top: 16px;
@@ -474,25 +474,32 @@
color: var(--accent-cyan, #00d4ff);
}
/* Map tile variants */
.tile-layer-cyan {
/* Map tile variants — teal tint, skipped in lean mode */
html:not([data-ui-tier="lean"]) .tile-layer-cyan {
filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05);
}
/* Global Leaflet map theme: cyber overlay */
/* Lean mode: suppress every map filter unconditionally */
html[data-ui-tier="lean"] .leaflet-tile-pane,
html[data-ui-tier="lean"] .leaflet-tile,
html[data-ui-tier="lean"] .tile-layer-cyan {
filter: none !important;
}
/* Global Leaflet map theme: cyber overlay — default and enhanced only, not lean */
.leaflet-container.map-theme-cyber {
position: relative;
background: #020813;
isolation: isolate;
}
.leaflet-container.map-theme-cyber .leaflet-tile-pane {
html:not([data-ui-tier="lean"]) .leaflet-container.map-theme-cyber .leaflet-tile-pane {
filter: sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08);
opacity: 1;
}
/* Hard global fallback: enforce cyber tint on all Leaflet tile images */
html.map-cyber-enabled .leaflet-container .leaflet-tile {
/* Hard global fallback: enforce cyber tint on all Leaflet tile images — not lean */
html:not([data-ui-tier="lean"]).map-cyber-enabled .leaflet-container .leaflet-tile {
filter: sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08) !important;
}
@@ -527,6 +534,58 @@ html.map-cyber-enabled .leaflet-container::after {
background-size: 52px 52px, 52px 52px;
}
/* Lean tier: no overlays, no filters — original tile colors */
html[data-ui-tier="lean"] .leaflet-container::before,
html[data-ui-tier="lean"] .leaflet-container::after {
background: none !important;
background-image: none !important;
}
/* Enhanced tier: signals teal map tint overrides — global (all Leaflet maps) */
html[data-ui-tier="enhanced"] .leaflet-container {
background: #000000;
}
html[data-ui-tier="enhanced"] .leaflet-container::before {
background: none !important;
}
html[data-ui-tier="enhanced"] .leaflet-tile-pane {
filter: sepia(0.85) hue-rotate(150deg) saturate(1.35) brightness(0.78) contrast(1.08);
}
/* Narrower overrides for cyber-specific classes (same filter, kept for !important precedence) */
html[data-ui-tier="enhanced"] .leaflet-container.map-theme-cyber {
background: #000000;
}
html[data-ui-tier="enhanced"] .tile-layer-cyan {
filter: sepia(0.85) hue-rotate(150deg) saturate(1.35) brightness(0.78) contrast(1.08);
}
html[data-ui-tier="enhanced"] .leaflet-container.map-theme-cyber .leaflet-tile-pane {
filter: sepia(0.85) hue-rotate(150deg) saturate(1.35) brightness(0.78) contrast(1.08);
}
html[data-ui-tier="enhanced"].map-cyber-enabled .leaflet-container {
background: #000000;
}
html[data-ui-tier="enhanced"].map-cyber-enabled .leaflet-container .leaflet-tile {
filter: sepia(0.85) hue-rotate(150deg) saturate(1.35) brightness(0.78) contrast(1.08) !important;
}
html[data-ui-tier="enhanced"].map-cyber-enabled .leaflet-container::before {
background: none;
}
html[data-ui-tier="enhanced"].map-cyber-enabled .leaflet-container::after {
background-image:
linear-gradient(rgba(46, 125, 138, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(46, 125, 138, 0.1) 1px, transparent 1px);
background-size: 52px 52px, 52px 52px;
}
/* Responsive */
@media (max-width: 1023px) {
.settings-tabs {
View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

+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 };
})();
+26 -45
View File
@@ -25,6 +25,10 @@ const ProximityRadar = (function() {
newDeviceThreshold: 30, // seconds
};
function _accent() {
return getComputedStyle(document.documentElement).getPropertyValue('--accent-cyan').trim() || '#00d4ff';
}
// State
let container = null;
let svg = null;
@@ -63,8 +67,8 @@ const ProximityRadar = (function() {
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" class="proximity-radar-svg">
<defs>
<radialGradient id="radarGradient" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="rgba(0, 212, 255, 0.1)" />
<stop offset="100%" stop-color="rgba(0, 212, 255, 0)" />
<stop offset="0%" style="stop-color:var(--accent-cyan);stop-opacity:0.1" />
<stop offset="100%" style="stop-color:var(--accent-cyan);stop-opacity:0" />
</radialGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
@@ -73,6 +77,9 @@ const ProximityRadar = (function() {
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<clipPath id="radarClip">
<circle cx="${center}" cy="${center}" r="${center - CONFIG.padding}"/>
</clipPath>
</defs>
<!-- Background gradient -->
@@ -94,14 +101,19 @@ const ProximityRadar = (function() {
}).join('')}
</g>
<!-- Sweep line (animated) -->
<line class="radar-sweep" x1="${center}" y1="${center}"
x2="${center}" y2="${CONFIG.padding}"
stroke="rgba(0, 212, 255, 0.5)" stroke-width="1" />
<!-- CSS-animated sweep group: trailing arcs + sweep line -->
<g class="bt-radar-sweep" clip-path="url(#radarClip)">
<path d="M${center},${center} L${center},${CONFIG.padding} A${center - CONFIG.padding},${center - CONFIG.padding} 0 0,1 ${center + (center - CONFIG.padding)},${center} Z"
style="fill:var(--accent-cyan)" opacity="0.035"/>
<path d="M${center},${center} L${center},${CONFIG.padding} A${center - CONFIG.padding},${center - CONFIG.padding} 0 0,1 ${Math.round(center + (center - CONFIG.padding) * Math.sin(Math.PI / 3))},${Math.round(center + (center - CONFIG.padding) * (1 - Math.cos(Math.PI / 3)))} Z"
style="fill:var(--accent-cyan)" opacity="0.07"/>
<line x1="${center}" y1="${center}" x2="${center}" y2="${CONFIG.padding}"
style="stroke:var(--accent-cyan)" stroke-width="1.5" opacity="0.75"/>
</g>
<!-- Center point -->
<circle cx="${center}" cy="${center}" r="${CONFIG.centerRadius}"
fill="#00d4ff" filter="url(#glow)" />
style="fill:var(--accent-cyan)" filter="url(#glow)" />
<!-- Device dots container -->
<g class="radar-devices"></g>
@@ -129,39 +141,6 @@ const ProximityRadar = (function() {
}
});
// Add sweep animation
animateSweep();
}
/**
* Animate the radar sweep line
*/
function animateSweep() {
const sweepLine = svg.querySelector('.radar-sweep');
if (!sweepLine) return;
let angle = 0;
const center = CONFIG.size / 2;
function rotate() {
if (isPaused) {
requestAnimationFrame(rotate);
return;
}
angle = (angle + 1) % 360;
const rad = (angle * Math.PI) / 180;
const radius = center - CONFIG.padding;
const x2 = center + Math.sin(rad) * radius;
const y2 = center - Math.cos(rad) * radius;
sweepLine.setAttribute('x2', x2);
sweepLine.setAttribute('y2', y2);
requestAnimationFrame(rotate);
}
requestAnimationFrame(rotate);
}
/**
@@ -263,7 +242,7 @@ const ProximityRadar = (function() {
dot.setAttribute('r', dotSize);
dot.setAttribute('fill', color);
dot.setAttribute('fill-opacity', isSelected ? 1 : 0.4 + confidence * 0.5);
dot.setAttribute('stroke', isSelected ? '#00d4ff' : color);
dot.setAttribute('stroke', isSelected ? _accent() : color);
dot.setAttribute('stroke-width', isSelected ? 2 : 1);
}
@@ -329,7 +308,7 @@ const ProximityRadar = (function() {
dot.setAttribute('r', dotSize);
dot.setAttribute('fill', color);
dot.setAttribute('fill-opacity', isSelected ? 1 : 0.4 + confidence * 0.5);
dot.setAttribute('stroke', isSelected ? '#00d4ff' : color);
dot.setAttribute('stroke', isSelected ? _accent() : color);
dot.setAttribute('stroke-width', isSelected ? 2 : 1);
innerG.appendChild(dot);
@@ -363,7 +342,7 @@ const ProximityRadar = (function() {
ring.classList.add('radar-select-ring');
ring.setAttribute('r', dotSize + 8);
ring.setAttribute('fill', 'none');
ring.setAttribute('stroke', '#00d4ff');
ring.setAttribute('stroke', _accent());
ring.setAttribute('stroke-width', '2');
ring.setAttribute('stroke-opacity', '0.8');
@@ -493,6 +472,8 @@ const ProximityRadar = (function() {
*/
function setPaused(paused) {
isPaused = paused;
const sweep = svg?.querySelector('.bt-radar-sweep');
if (sweep) sweep.style.animationPlayState = paused ? 'paused' : 'running';
}
/**
@@ -560,7 +541,7 @@ const ProximityRadar = (function() {
const dot = el.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
dot.setAttribute('fill-opacity', '1');
dot.setAttribute('stroke', '#00d4ff');
dot.setAttribute('stroke', _accent());
dot.setAttribute('stroke-width', '2');
}
@@ -571,7 +552,7 @@ const ProximityRadar = (function() {
ring.classList.add('radar-select-ring');
ring.setAttribute('r', dotSize + 8);
ring.setAttribute('fill', 'none');
ring.setAttribute('stroke', '#00d4ff');
ring.setAttribute('stroke', _accent());
ring.setAttribute('stroke-width', '2');
ring.setAttribute('stroke-opacity', '0.8');
+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 };
})();
+6 -6
View File
@@ -289,10 +289,10 @@ const SignalGuess = (function() {
regions: ['GLOBAL']
},
// Key Fob
{
label: 'Remote Control / Key Fob',
tags: ['remote', 'keyfob', 'automotive', 'burst', 'ism'],
// Key Fob
{
label: 'Remote Control / Key Fob',
tags: ['remote', 'keyfob', 'automotive', 'burst', 'ism'],
description: 'Wireless remote control or vehicle key fob',
frequencyRanges: [[314900000, 315100000], [433050000, 434790000], [867000000, 869000000]],
modulationHints: ['OOK', 'ASK', 'FSK', 'rolling'],
@@ -809,8 +809,8 @@ const SignalGuess = (function() {
transition: all 0.15s ease;
}
.signal-guess-why:hover {
border-color: #00d4ff;
color: #00d4ff;
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.signal-guess-tags {
display: flex;
+33 -21
View File
@@ -10,10 +10,11 @@ let currentAgent = 'local';
let agentEventSource = null;
let multiAgentMode = false; // Show combined results from all agents
let multiAgentPollInterval = null;
let agentRunningModes = []; // Track agent's running modes for conflict detection
let agentRunningModesDetail = {}; // Track device info per mode (for multi-SDR agents)
let healthCheckInterval = null; // Health monitoring interval
let agentHealthStatus = {}; // Cache of health status per agent ID
let agentRunningModes = []; // Track agent's running modes for conflict detection
let agentRunningModesDetail = {}; // Track device info per mode (for multi-SDR agents)
let healthCheckInterval = null; // Health monitoring interval
let agentHealthStatus = {}; // Cache of health status per agent ID
let healthCheckKickoffTimer = null;
// ============== AGENT HEALTH MONITORING ==============
@@ -21,27 +22,38 @@ let agentHealthStatus = {}; // Cache of health status per agent ID
* Start periodic health monitoring for all agents.
* Runs every 30 seconds to check agent health status.
*/
function startHealthMonitoring() {
// Don't start if already running
if (healthCheckInterval) return;
// Initial check
checkAllAgentsHealth();
// Start periodic checks every 30 seconds
healthCheckInterval = setInterval(checkAllAgentsHealth, 30000);
console.log('[AgentManager] Health monitoring started (30s interval)');
}
function startHealthMonitoring() {
// Don't start if already running
if (healthCheckInterval) return;
// Defer the first probe so heavy dashboards can finish initial render
// before we start contacting remote agents.
if (healthCheckKickoffTimer) {
clearTimeout(healthCheckKickoffTimer);
}
healthCheckKickoffTimer = setTimeout(() => {
healthCheckKickoffTimer = null;
checkAllAgentsHealth();
}, 5000);
// Start periodic checks every 30 seconds
healthCheckInterval = setInterval(checkAllAgentsHealth, 30000);
console.log('[AgentManager] Health monitoring started (30s interval)');
}
/**
* Stop health monitoring.
*/
function stopHealthMonitoring() {
if (healthCheckInterval) {
clearInterval(healthCheckInterval);
healthCheckInterval = null;
console.log('[AgentManager] Health monitoring stopped');
}
function stopHealthMonitoring() {
if (healthCheckKickoffTimer) {
clearTimeout(healthCheckKickoffTimer);
healthCheckKickoffTimer = null;
}
if (healthCheckInterval) {
clearInterval(healthCheckInterval);
healthCheckInterval = null;
console.log('[AgentManager] Health monitoring stopped');
}
}
/**

Some files were not shown because too many files have changed in this diff Show More