Compare commits

...

21 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
26 changed files with 2329 additions and 210 deletions
+11 -2
View File
@@ -15,12 +15,21 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
group: ["a-l", "m-z"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: '3.11' python-version: '3.11'
- run: pip install -r requirements-dev.txt - run: pip install -r requirements-dev.txt
- name: Run tests - name: Run tests (${{ matrix.group }})
run: pytest --tb=short -q 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 continue-on-error: true
+12 -12
View File
@@ -4,8 +4,8 @@
TLE_SATELLITES = { TLE_SATELLITES = {
"ISS": ( "ISS": (
"ISS (ZARYA)", "ISS (ZARYA)",
"1 25544U 98067A 26140.52007258 .00005164 00000+0 10084-3 0 9993", "1 25544U 98067A 23321.52083333 .00016717 00000-0 30171-3 0 9992",
"2 25544 51.6328 77.0641 0007497 79.3410 280.8422 15.49283153567468", "2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123456",
), ),
"NOAA-15": ( "NOAA-15": (
"NOAA 15", "NOAA 15",
@@ -24,27 +24,27 @@ TLE_SATELLITES = {
), ),
"NOAA-20": ( "NOAA-20": (
"NOAA 20 (JPSS-1)", "NOAA 20 (JPSS-1)",
"1 43013U 17073A 26140.44110773 .00000055 00000+0 46930-4 0 9994", "1 43013U 17073A 26141.21646093 .00000052 00000+0 45436-4 0 9996",
"2 43013 98.7764 80.1520 0001265 43.4537 316.6738 14.19505991440534", "2 43013 98.7764 80.9203 0001233 42.6389 317.4882 14.19506117440643",
), ),
"NOAA-21": ( "NOAA-21": (
"NOAA 21 (JPSS-2)", "NOAA 21 (JPSS-2)",
"1 54234U 22150A 26140.47502274 .00000020 00000+0 29984-4 0 9999", "1 54234U 22150A 26141.25034758 .00000025 00000+0 32664-4 0 9997",
"2 54234 98.7052 79.7311 0000538 296.4939 63.6182 14.19559760182618", "2 54234 98.7052 80.4933 0000516 290.1874 69.9247 14.19559916182728",
), ),
"METEOR-M2": ( "METEOR-M2": (
"METEOR-M 2", "METEOR-M 2",
"1 40069U 14037A 26140.48222780 .00000329 00000+0 16961-3 0 9999", "1 40069U 14037A 26141.25652306 .00000366 00000+0 18646-3 0 9999",
"2 40069 98.5104 117.2052 0006833 111.5029 248.6878 14.21453950615385", "2 40069 98.5106 117.9520 0006860 109.5984 250.5935 14.21454410615491",
), ),
"METEOR-M2-3": ( "METEOR-M2-3": (
"METEOR-M2 3", "METEOR-M2 3",
"1 57166U 23091A 26140.55562749 -.00000013 00000+0 13331-4 0 9995", "1 57166U 23091A 26141.32851392 -.00000014 00000+0 12575-4 0 9996",
"2 57166 98.6097 196.0965 0002883 242.0522 118.0365 14.24044155150583", "2 57166 98.6097 196.8537 0002910 239.0757 121.0137 14.24044204150691",
), ),
"METEOR-M2-4": ( "METEOR-M2-4": (
"METEOR-M2 4", "METEOR-M2 4",
"1 59051U 24039A 26140.53898488 .00000003 00000+0 20858-4 0 9993", "1 59051U 24039A 26141.24240655 .00000007 00000+0 22827-4 0 9991",
"2 59051 98.6996 100.1874 0005955 247.0139 113.0410 14.22426327115336", "2 59051 98.6997 100.8818 0005969 244.5272 115.5289 14.22426426115439",
), ),
} }
@@ -0,0 +1,133 @@
# Pager & 433 Sensor Display Revamp
**Date:** 2026-05-21
**Status:** Approved
## Overview
Replace the plain chronological card feed for the Pager and 433 Sensor modes with purpose-built views that better surface the structure of each signal type. Both new views are opt-out (toggle to classic feed available).
---
## Architecture
The two modes use slightly different DOM strategies suited to each layout.
**Pager:** `#pagerDirectoryView` is the left directory panel only. The output panel parent switches to `display: flex` in directory mode, placing the directory panel and `#output` side by side. `#output` becomes the right feed panel — no duplication, no hidden copy.
**Sensor:** `#sensorDashboardView` is a full-replacement grid that sits alongside `#output`. In dashboard mode `#output` is hidden but continues to receive classic `signal-card` insertions so export and filtering remain intact.
```
[output-panel] (flex in pager directory mode)
[#pagerDirectoryView] ← left dir panel only; shown in pager directory mode
[#sensorDashboardView] ← full replacement grid; shown in sensor dashboard mode
[#output] ← right feed panel (pager) or hidden (sensor); always updated
```
`addMessage()` gets a hook to `PagerDirectory.addMessage()` for directory panel updates only (the feed is `#output` itself). `addSensorReading()` gets a hook to `SensorDashboard.addReading()` for station card updates. No other existing logic changes.
### New files
| File | Purpose |
|------|---------|
| `static/js/components/pager-directory.js` | PagerDirectory component |
| `static/js/components/sensor-dashboard.js` | SensorDashboard component |
| `static/css/components/pager-directory.css` | Directory view styles |
| `static/css/components/sensor-dashboard.css` | Dashboard view styles |
`templates/index.html` gets:
- Two new sibling containers (`#pagerDirectoryView`, `#sensorDashboardView`)
- Toggle buttons in the output panel header (one per mode, shown when that mode is active)
- Script/link tags for the four new files
- One-line hook calls inside `addMessage()` and `addSensorReading()`
---
## Pager — Source Directory View
### Layout
Split panel, full height of the output area:
- **Left (200 px fixed):** address directory panel
- **Right (flex):** full message feed
### Directory panel (left)
- One row per unique pager address seen this session
- Sorted by message count descending (most active at top)
- Each row shows:
- Protocol badge (`P` = POCSAG, `F` = FLEX), coloured accordingly
- Address string
- Message count (`×24`)
- Relative-width activity bar (count relative to the highest-count address)
- Last-seen relative timestamp (`just now`, `2m ago`)
- Green dot when a new message arrives from that address (fades after 3 s)
- Blue left-border accent on the currently highlighted address
- Directory state is in-memory for the session only (not persisted)
### Feed panel (right)
- Shows **all messages** at all times (no filtering)
- When an address is highlighted via the directory:
- Feed scrolls to that address's most recent card
- All cards from that address get a blue left-border + subtle background tint
- Sub-header shows `"<address> highlighted"` with a "clear highlight" link
- Clicking "clear highlight" (or clicking the same address again) removes all highlighting and returns to the plain feed
- Cards are otherwise identical to the existing `signal-card` format
### Toggle
- Button group top-right of the output panel header: **Directory** | **Feed**
- Default: **Directory**
- Preference saved to `localStorage` key `pagerView` (`'directory'` | `'feed'`)
- Restored on mode switch
---
## 433 Sensor — Station Dashboard View
### Layout
Responsive CSS grid of station cards (3 columns on typical desktop width, wrapping as needed).
### Station card
One persistent card per unique device, keyed by `model + id`. Cards are created on first reading and updated in place on subsequent readings from the same device.
Each card contains:
- **Header:** device model name (e.g. `Acurite-Tower`), device ID + channel, last-seen relative timestamp (green when < 10 s)
- **Readings:** the primary numeric values for that device (temperature, humidity, pressure, wind speed, rain, etc.) — label + value + unit, displayed as a small inline grid
- **Sparkline:** SVG polyline tracking the primary numeric value across the last 30 readings. Colour matches the reading type (amber for temperature, blue for humidity/wind, purple for pressure). A filled circle marks the latest data point.
- **Footer:** battery status (green `BAT OK` / red `BAT LOW`), SNR value, frequency badge
### State-only devices
Devices that emit only a state (doorbells, PIR sensors, etc.) get a card with a state indicator (coloured dot + label e.g. `MOTION DETECTED`) in place of numeric readings. The sparkline area is replaced with an "event-only device" label. Card still flashes on each event.
### Flash on update
When a new reading arrives for a known device:
- Card receives a CSS animation class that briefly tints the background (blue for temp sensors, purple for other types) and fades back to normal over ~0.8 s
- Values update in place; the sparkline dot advances right
### New device appearance
First time a device is seen: card slides in with a subtle green border accent. The border fades to normal after the first update.
### Toggle
- Button group top-right of output panel header: **Dashboard** | **Feed**
- Default: **Dashboard**
- Preference saved to `localStorage` key `sensorView` (`'dashboard'` | `'feed'`)
- Restored on mode switch
---
## Shared behaviour
- Both toggles are shown only when the relevant mode is active
- Classic `#output` feed always receives cards in the background (export, CSV/JSON, existing filter bar all continue to work)
- No changes to SSE handling, process management, or backend routes
- No new backend endpoints required
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -27,13 +27,16 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"flask>=3.0.0", "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", "skyfield>=1.45",
"pyserial>=3.5", "pyserial>=3.5",
"Werkzeug>=3.1.5", "Werkzeug>=3.1.5",
"flask-limiter>=2.5.4",
"bleak>=0.21.0", "bleak>=0.21.0",
"flask-sock",
"websocket-client>=1.6.0",
"requests>=2.28.0", "requests>=2.28.0",
] ]
@@ -51,6 +54,7 @@ dev = [
"black>=23.0.0", "black>=23.0.0",
"mypy>=1.0.0", "mypy>=1.0.0",
"types-flask>=1.1.0", "types-flask>=1.1.0",
"pre-commit>=3.0.0",
] ]
optionals = [ optionals = [
@@ -59,8 +63,13 @@ optionals = [
"numpy>=1.24.0", "numpy>=1.24.0",
"Pillow>=9.0.0", "Pillow>=9.0.0",
"meshtastic>=2.0.0", "meshtastic>=2.0.0",
"meshcore>=1.0.0",
"psycopg2-binary>=2.9.9", "psycopg2-binary>=2.9.9",
"scapy>=2.4.5", "scapy>=2.4.5",
"cryptography>=41.0.0",
"psutil>=5.9.0",
"gunicorn>=21.2.0",
"gevent>=23.9.0",
] ]
[project.scripts] [project.scripts]
+3 -1
View File
@@ -1786,7 +1786,9 @@ def aircraft_photo(registration: str):
try: try:
# Planespotters.net public API # Planespotters.net public API
url = f"https://api.planespotters.net/pub/photos/reg/{registration}" url = f"https://api.planespotters.net/pub/photos/reg/{registration}"
resp = requests.get(url, timeout=5, headers={"User-Agent": "INTERCEPT-ADS-B/1.0"}) resp = requests.get(
url, timeout=5, headers={"User-Agent": "INTERCEPT-ADS-B/2.27 (+https://github.com/smittix/intercept)"}
)
if resp.status_code == 200: if resp.status_code == 200:
data = resp.json() data = resp.json()
+13
View File
@@ -186,6 +186,19 @@ def load_seen_device_ids() -> set[str]:
return {row["device_id"] for row in cursor} return {row["device_id"] for row in cursor}
# =============================================================================
# HELPERS
# =============================================================================
def device_to_dict(device: BTDeviceAggregate) -> dict:
"""Serialize a BTDeviceAggregate to a JSON-safe dict with heuristics flattened to top level."""
d = device.to_dict()
heuristics = d.pop("heuristics", {})
d.update(heuristics)
return d
# ============================================================================= # =============================================================================
# API ENDPOINTS # API ENDPOINTS
# ============================================================================= # =============================================================================
+179
View File
@@ -0,0 +1,179 @@
/* ============================================================
Signal View Wrap — flex container for split-panel layouts
============================================================ */
#signalViewWrap {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Feed column — wraps feed header + #output, fills remaining space */
.pdir-feed-col {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Feed header strip — shown in directory mode above the message list */
.pdir-feed-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 10px;
background: var(--bg-card);
border-bottom: 1px solid var(--border-color);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-secondary);
flex-shrink: 0;
}
.pdir-clear-btn {
background: none;
border: none;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--text-xs);
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-sm);
transition: color var(--transition-fast);
}
.pdir-clear-btn:hover { color: var(--text-dim); }
/* ---- Directory panel (left side of split) ---- */
.pdir-panel {
display: flex;
width: 200px;
flex-shrink: 0;
border-right: 1px solid var(--border-color);
flex-direction: column;
overflow: hidden;
background: var(--bg-secondary);
font-family: var(--font-mono);
}
.pdir-header {
padding: 6px 10px;
font-size: var(--text-xs);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-card);
flex-shrink: 0;
}
.pdir-entries {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* ---- Individual address entry ---- */
.pdir-entry {
padding: 7px 10px;
border-bottom: 1px solid rgba(var(--accent-cyan-rgb), 0.04);
cursor: pointer;
position: relative;
transition: background var(--transition-fast);
}
.pdir-entry:hover { background: var(--bg-tertiary); }
.pdir-entry--active {
background: rgba(var(--accent-cyan-rgb), 0.06);
border-left: 2px solid var(--accent-cyan);
padding-left: 8px;
}
.pdir-entry-top {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 3px;
}
.pdir-proto {
font-size: 8px;
padding: 1px 4px;
border-radius: var(--radius-sm);
font-weight: var(--font-bold);
flex-shrink: 0;
}
.pdir-proto--p { background: rgba(var(--accent-cyan-rgb), 0.15); color: var(--accent-cyan); }
.pdir-proto--f { background: rgba(var(--accent-purple-rgb), 0.15); color: var(--accent-purple); }
.pdir-addr {
font-size: var(--text-xs);
color: var(--text-secondary);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pdir-new-dot {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-green);
flex-shrink: 0;
opacity: 0;
}
.pdir-new-dot--active {
animation: pdir-dot-fade 3s ease-out forwards;
}
@keyframes pdir-dot-fade {
0% { opacity: 1; }
85% { opacity: 1; }
100% { opacity: 0; }
}
.pdir-count { font-size: 9px; color: var(--text-muted); flex-shrink: 0; }
.pdir-bar-wrap { height: 2px; background: var(--bg-tertiary); border-radius: 1px; margin-bottom: 2px; }
.pdir-bar { height: 2px; background: var(--accent-cyan); border-radius: 1px; transition: width var(--transition-slow); }
.pdir-bar--flex { background: var(--accent-purple); }
.pdir-age { font-size: 8px; color: var(--text-muted); }
/* ---- Highlight applied to signal-cards in #output ---- */
.signal-card.pdir-hl {
border-left: 2px solid var(--accent-cyan) !important;
background: rgba(var(--accent-cyan-rgb), 0.04) !important;
}
/* ---- View toggle button group (inside .stats) ---- */
.stats .view-toggle-group { display: none; }
.stats.active .view-toggle-group { display: flex; }
.view-toggle-group {
gap: 2px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 2px;
margin-left: 6px;
}
.view-toggle-btn {
padding: 2px 8px;
font-size: 9px;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.5px;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-muted);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.view-toggle-btn:hover { color: var(--text-dim); }
.view-toggle-btn--active {
background: var(--accent-cyan-dim);
color: var(--accent-cyan);
}
+124
View File
@@ -0,0 +1,124 @@
/* ============================================================
Sensor Dashboard View
============================================================ */
.sdb-view {
flex: 1;
overflow-y: auto;
min-height: 0;
background: var(--bg-primary);
}
.sdb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px;
padding: 10px;
}
/* ---- Station card ---- */
.sdb-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 10px;
font-family: var(--font-mono);
overflow: hidden;
}
.sdb-card--new {
border-color: rgba(var(--accent-green-rgb), 0.3);
animation: sdb-slide-in 0.4s ease-out;
}
@keyframes sdb-slide-in {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: none; }
}
.sdb-card--flash-blue {
animation: sdb-flash-blue 0.8s ease-out;
}
@keyframes sdb-flash-blue {
0% { background: rgba(var(--accent-cyan-rgb), 0.10); border-color: rgba(var(--accent-cyan-rgb), 0.30); }
100% { background: var(--bg-card); border-color: var(--border-color); }
}
.sdb-card--flash-purple {
animation: sdb-flash-purple 0.8s ease-out;
}
@keyframes sdb-flash-purple {
0% { background: rgba(var(--accent-purple-rgb), 0.10); border-color: rgba(var(--accent-purple-rgb), 0.30); }
100% { background: var(--bg-card); border-color: var(--border-color); }
}
/* ---- Card header ---- */
.sdb-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.sdb-name {
font-size: var(--text-xs);
color: var(--accent-cyan);
font-weight: var(--font-semibold);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
.sdb-id { font-size: 8px; color: var(--text-muted); margin-top: 1px; }
.sdb-age { font-size: 8px; color: var(--text-muted); white-space: nowrap; }
.sdb-age--fresh { color: var(--accent-green); }
/* ---- Readings grid ---- */
.sdb-readings {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 8px;
min-height: 36px;
align-items: flex-end;
}
.sdb-reading { text-align: center; min-width: 34px; }
.sdb-reading-val { font-size: 15px; font-weight: var(--font-bold); line-height: 1; }
.sdb-reading-unit { font-size: 8px; color: var(--text-muted); }
.sdb-reading-label { font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 1px; }
.sdb-no-readings { font-size: 9px; color: var(--text-muted); align-self: center; }
/* ---- State-only device ---- */
.sdb-state { display: flex; align-items: center; gap: 6px; min-height: 36px; }
.sdb-state-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.sdb-state-dot--on { background: var(--accent-green); box-shadow: 0 0 5px var(--accent-green); }
.sdb-state-dot--off { background: var(--text-muted); }
.sdb-state-label { font-size: 9px; color: var(--text-secondary); }
/* ---- Sparkline ---- */
.sdb-spark { margin-bottom: 6px; }
.sdb-spark svg { width: 100%; height: 22px; display: block; }
.sdb-spark-placeholder {
height: 22px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
padding: 0 6px;
font-size: 8px;
color: var(--text-muted);
}
/* ---- Card footer ---- */
.sdb-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 8px;
}
.sdb-bat--ok { color: var(--accent-green); }
.sdb-bat--low { color: var(--accent-red); }
.sdb-snr { color: var(--text-muted); }
.sdb-freq {
padding: 1px 4px;
border-radius: var(--radius-sm);
background: var(--bg-secondary);
color: var(--text-muted);
}
+30
View File
@@ -106,6 +106,36 @@
white-space: nowrap; 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 --- /* --- Dark glass popup ---
Applied via MapUtils.glassPopupOptions() className. */ Applied via MapUtils.glassPopupOptions() className. */
+2
View File
@@ -45,6 +45,8 @@
--accent-amber-dim: rgba(214, 168, 94, 0.18); --accent-amber-dim: rgba(214, 168, 94, 0.18);
--accent-yellow: #e1c26b; --accent-yellow: #e1c26b;
--accent-purple: #8f7bd6; --accent-purple: #8f7bd6;
--accent-purple-rgb: 143, 123, 214;
--accent-green-rgb: 56, 193, 128;
/* Text hierarchy */ /* Text hierarchy */
--text-primary: #d7e0ee; --text-primary: #d7e0ee;
+198
View File
@@ -0,0 +1,198 @@
const PagerDirectory = (function () {
'use strict';
const STORAGE_KEY = 'pagerView';
// Map<address, { count, protocol, lastSeen }>
const addresses = new Map();
let highlighted = null;
// ---- Helpers ----
function esc(str) {
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function formatAge(ts) {
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 10) return 'just now';
if (s < 60) return `${s}s ago`;
return `${Math.floor(s / 60)}m ago`;
}
// ---- Directory rendering ----
function renderDirectory() {
const entriesEl = document.getElementById('pagerDirEntries');
const countEl = document.getElementById('pagerDirCount');
if (!entriesEl) return;
const sorted = [...addresses.entries()].sort((a, b) => b[1].count - a[1].count);
const maxCount = sorted.length > 0 ? sorted[0][1].count : 1;
if (countEl) countEl.textContent = sorted.length;
sorted.forEach(([addr, data]) => {
let el = entriesEl.querySelector(`[data-pdir-addr="${CSS.escape(addr)}"]`);
const isActive = addr === highlighted;
const pct = Math.round((data.count / maxCount) * 100);
const isPocsag = data.protocol !== 'flex';
const protoClass = isPocsag ? 'pdir-proto--p' : 'pdir-proto--f';
const barClass = isPocsag ? '' : 'pdir-bar--flex';
const html = `
<div class="pdir-entry-top">
<span class="pdir-proto ${protoClass}">${isPocsag ? 'P' : 'F'}</span>
<span class="pdir-addr">${esc(addr)}</span>
<span class="pdir-new-dot"></span>
<span class="pdir-count">×${data.count}</span>
</div>
<div class="pdir-bar-wrap"><div class="pdir-bar ${barClass}" style="width:${pct}%"></div></div>
<div class="pdir-age">${formatAge(data.lastSeen)}</div>`;
if (!el) {
el = document.createElement('div');
el.className = 'pdir-entry';
el.dataset.pdirAddr = addr;
el.addEventListener('click', () => toggleHighlight(addr));
entriesEl.appendChild(el);
}
el.classList.toggle('pdir-entry--active', isActive);
el.innerHTML = html;
});
// Re-order DOM to match sort
sorted.forEach(([addr]) => {
const el = entriesEl.querySelector(`[data-pdir-addr="${CSS.escape(addr)}"]`);
if (el) entriesEl.appendChild(el);
});
}
function flashNewDot(addr) {
// Find the dot inside this entry after the current render frame
setTimeout(() => {
const entriesEl = document.getElementById('pagerDirEntries');
const entry = entriesEl?.querySelector(`[data-pdir-addr="${CSS.escape(addr)}"]`);
const dot = entry?.querySelector('.pdir-new-dot');
if (!dot) return;
dot.classList.remove('pdir-new-dot--active');
void dot.offsetWidth; // force reflow to restart animation
dot.classList.add('pdir-new-dot--active');
}, 0);
}
// ---- Highlight ----
function toggleHighlight(addr) {
if (highlighted === addr) clearHighlight();
else highlight(addr);
}
function highlight(addr) {
highlighted = addr;
renderDirectory();
const feedLabel = document.getElementById('pagerFeedLabel');
const clearBtn = document.getElementById('pagerClearHighlight');
if (feedLabel) feedLabel.textContent = `${addr} highlighted`;
if (clearBtn) clearBtn.style.display = 'inline';
const output = document.getElementById('output');
if (!output) return;
output.querySelectorAll('.signal-card').forEach(card => {
card.classList.toggle('pdir-hl', card.dataset.address === addr);
});
const first = output.querySelector(`.signal-card[data-address="${CSS.escape(addr)}"]`);
if (first) first.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function clearHighlight() {
highlighted = null;
renderDirectory();
const feedLabel = document.getElementById('pagerFeedLabel');
const clearBtn = document.getElementById('pagerClearHighlight');
if (feedLabel) feedLabel.textContent = 'All messages';
if (clearBtn) clearBtn.style.display = 'none';
document.getElementById('output')
?.querySelectorAll('.pdir-hl')
.forEach(c => c.classList.remove('pdir-hl'));
}
// ---- Public: message hook ----
function addMessage(msg) {
const addr = msg.address;
if (!addr) return;
const proto = (msg.protocol || '').includes('FLEX') ? 'flex' : 'pocsag';
const entry = addresses.get(addr);
if (entry) {
entry.count++;
entry.lastSeen = Date.now();
entry.protocol = proto;
} else {
addresses.set(addr, { count: 1, protocol: proto, lastSeen: Date.now() });
}
renderDirectory();
flashNewDot(addr);
// Re-apply highlight class to the newly inserted card (caller inserts it after this hook)
if (highlighted === addr) {
setTimeout(() => {
const output = document.getElementById('output');
output?.querySelectorAll(`.signal-card[data-address="${CSS.escape(addr)}"]`)
.forEach(c => c.classList.add('pdir-hl'));
}, 0);
}
}
// ---- Show / hide / reset ----
function applyViewState(mode) {
const dirPanel = document.getElementById('pagerDirectoryView');
const feedHeader = document.getElementById('pagerFeedHeader');
if (mode === 'pager') {
const saved = localStorage.getItem(STORAGE_KEY) || 'directory';
const isDir = saved === 'directory';
if (dirPanel) dirPanel.style.display = isDir ? 'flex' : 'none';
if (feedHeader) feedHeader.style.display = isDir ? 'flex' : 'none';
_updateToggle(isDir);
renderDirectory();
} else {
if (dirPanel) dirPanel.style.display = 'none';
if (feedHeader) feedHeader.style.display = 'none';
clearHighlight();
}
}
function show() {
localStorage.setItem(STORAGE_KEY, 'directory');
applyViewState('pager');
}
function hide() {
localStorage.setItem(STORAGE_KEY, 'feed');
applyViewState('pager');
}
function _updateToggle(isDir) {
document.getElementById('pagerToggleDir')?.classList.toggle('view-toggle-btn--active', isDir);
document.getElementById('pagerToggleFeed')?.classList.toggle('view-toggle-btn--active', !isDir);
}
function reset() {
addresses.clear();
highlighted = null;
const entriesEl = document.getElementById('pagerDirEntries');
const countEl = document.getElementById('pagerDirCount');
if (entriesEl) entriesEl.innerHTML = '';
if (countEl) countEl.textContent = '0';
clearHighlight();
}
return { addMessage, highlight, clearHighlight, show, hide, reset, applyViewState };
})();
+201
View File
@@ -0,0 +1,201 @@
const SensorDashboard = (function () {
'use strict';
const STORAGE_KEY = 'sensorView';
const MAX_SPARK_PTS = 30;
// Map<deviceKey, { card: HTMLElement, history: number[], primaryColor: string }>
const devices = new Map();
// ---- Helpers ----
function esc(str) {
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function formatAge(timestamp) {
if (!timestamp) return '';
const ts = typeof timestamp === 'string' ? new Date(timestamp).getTime() : Number(timestamp);
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 10) return 'just now';
if (s < 60) return `${s}s ago`;
return `${Math.floor(s / 60)}m ago`;
}
function isRecent(timestamp) {
if (!timestamp) return false;
const ts = typeof timestamp === 'string' ? new Date(timestamp).getTime() : Number(timestamp);
return (Date.now() - ts) < 10000;
}
// ---- Primary value for sparkline ----
function getPrimary(msg) {
if (msg.temperature !== undefined)
return { value: msg.temperature, color: '#f59e0b' };
if (msg.pressure !== undefined)
return { value: msg.pressure, color: '#a78bfa' };
if (msg.wind_speed !== undefined)
return { value: msg.wind_speed, color: '#4aa3ff' };
return null;
}
function getFlashClass(msg) {
return msg.temperature !== undefined ? 'sdb-card--flash-blue' : 'sdb-card--flash-purple';
}
// ---- HTML builders ----
function buildReadingsHTML(msg) {
// State-only device (no continuous numeric field)
if (msg.state !== undefined && msg.temperature === undefined
&& msg.pressure === undefined && msg.wind_speed === undefined) {
const raw = String(msg.state);
const isOn = raw === '1' || raw === 'true' || raw === 'on' || raw === 'active';
return `<div class="sdb-state">
<span class="sdb-state-dot ${isOn ? 'sdb-state-dot--on' : 'sdb-state-dot--off'}"></span>
<span class="sdb-state-label">${esc(raw.toUpperCase())}</span>
</div>`;
}
const parts = [];
if (msg.temperature !== undefined)
parts.push({ val: msg.temperature, unit: `°${msg.temperature_unit || 'C'}`, label: 'Temp', color: '#f59e0b' });
if (msg.humidity !== undefined)
parts.push({ val: msg.humidity, unit: '%', label: 'Humid', color: '#38bdf8' });
if (msg.pressure !== undefined)
parts.push({ val: msg.pressure, unit: msg.pressure_unit || 'hPa', label: 'Press', color: '#a78bfa' });
if (msg.wind_speed !== undefined)
parts.push({ val: msg.wind_speed, unit: msg.wind_unit || 'km/h', label: 'Wind', color: '#4aa3ff' });
if (msg.rain !== undefined)
parts.push({ val: msg.rain, unit: msg.rain_unit || 'mm', label: 'Rain', color: '#38bdf8' });
if (parts.length === 0)
return `<div class="sdb-no-readings">No numeric data</div>`;
return parts.map(p => `
<div class="sdb-reading">
<div class="sdb-reading-val" style="color:${p.color}">${esc(String(p.val))}</div>
<div class="sdb-reading-unit">${esc(p.unit)}</div>
<div class="sdb-reading-label">${p.label}</div>
</div>`).join('');
}
function buildSparklineHTML(history, color) {
if (history.length < 2)
return `<div class="sdb-spark-placeholder">Collecting data…</div>`;
const W = 120, H = 22, PAD = 2;
const min = Math.min(...history);
const max = Math.max(...history);
const range = max - min || 1;
const pts = history.map((v, i) => {
const x = (i / (history.length - 1)) * (W - PAD * 2) + PAD;
const y = H - PAD - ((v - min) / range) * (H - PAD * 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
const last = pts.split(' ').pop().split(',');
return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<rect fill="var(--bg-secondary)" width="${W}" height="${H}"/>
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5" opacity="0.85"/>
<circle cx="${last[0]}" cy="${last[1]}" r="2" fill="${color}"/>
</svg>`;
}
function buildCardHTML(msg, history, primaryColor) {
const age = formatAge(msg.timestamp);
const fresh = isRecent(msg.timestamp);
const batLow = msg.battery === 'LOW';
const sparkHTML = history.length > 0
? buildSparklineHTML(history, primaryColor || '#4aa3ff')
: `<div class="sdb-spark-placeholder">Waiting for data…</div>`;
return `
<div class="sdb-card-header">
<div>
<div class="sdb-name">${esc(msg.model || 'Unknown')}</div>
<div class="sdb-id">ID ${esc(String(msg.id || 'N/A'))}${msg.channel ? ` · Ch ${esc(String(msg.channel))}` : ''}</div>
</div>
<div class="sdb-age${fresh ? ' sdb-age--fresh' : ''}">${age}</div>
</div>
<div class="sdb-readings">${buildReadingsHTML(msg)}</div>
<div class="sdb-spark">${sparkHTML}</div>
<div class="sdb-footer">
${msg.battery ? `<span class="sdb-bat ${batLow ? 'sdb-bat--low' : 'sdb-bat--ok'}">● BAT ${esc(msg.battery)}</span>` : '<span></span>'}
${msg.snr !== undefined ? `<span class="sdb-snr">SNR ${esc(String(msg.snr))} dB</span>` : '<span></span>'}
${msg.frequency ? `<span class="sdb-freq">${esc(String(msg.frequency))}</span>` : '<span></span>'}
</div>`;
}
// ---- Public: reading hook ----
function addReading(msg) {
const key = `${msg.model || 'Unknown'}_${msg.id || msg.channel || '0'}`;
const primary = getPrimary(msg);
if (devices.has(key)) {
const dev = devices.get(key);
if (primary) {
dev.history.push(primary.value);
if (dev.history.length > MAX_SPARK_PTS) dev.history.shift();
dev.primaryColor = primary.color;
}
dev.card.innerHTML = buildCardHTML(msg, dev.history, dev.primaryColor);
const cls = getFlashClass(msg);
dev.card.classList.add(cls);
setTimeout(() => dev.card.classList.remove(cls), 820);
} else {
const history = primary ? [primary.value] : [];
const grid = document.getElementById('sensorDashboardGrid');
if (!grid) return;
const card = document.createElement('div');
card.className = 'sdb-card sdb-card--new';
card.innerHTML = buildCardHTML(msg, history, primary ? primary.color : '#4aa3ff');
grid.insertBefore(card, grid.firstChild);
setTimeout(() => card.classList.remove('sdb-card--new'), 2000);
devices.set(key, { card, history, primaryColor: primary ? primary.color : '#4aa3ff' });
}
}
// ---- Show / hide / reset ----
function applyViewState(mode) {
const view = document.getElementById('sensorDashboardView');
const output = document.getElementById('output');
if (mode === 'sensor') {
const saved = localStorage.getItem(STORAGE_KEY) || 'dashboard';
const isDash = saved === 'dashboard';
if (view) view.style.display = isDash ? 'block' : 'none';
if (output) output.style.display = isDash ? 'none' : '';
_updateToggle(isDash);
} else {
if (view) view.style.display = 'none';
}
}
function show() {
localStorage.setItem(STORAGE_KEY, 'dashboard');
applyViewState('sensor');
}
function hide() {
localStorage.setItem(STORAGE_KEY, 'feed');
applyViewState('sensor');
}
function _updateToggle(isDash) {
document.getElementById('sensorToggleDash')?.classList.toggle('view-toggle-btn--active', isDash);
document.getElementById('sensorToggleFeed')?.classList.toggle('view-toggle-btn--active', !isDash);
}
function reset() {
devices.clear();
const grid = document.getElementById('sensorDashboardGrid');
if (grid) grid.innerHTML = '';
}
return { addReading, show, hide, reset, applyViewState };
})();
+74 -27
View File
@@ -118,6 +118,72 @@ const MapUtils = {
return layer; return layer;
}, },
/**
* Add a graticule (lat/lon grid) toggle button control to any Leaflet map.
*
* @param {L.Map} map
* @param {Object} [options]
* @param {boolean} [options.defaultVisible=true] Show grid on init.
* @param {string} [options.position='bottomleft'] Leaflet control position.
* @returns {{ control: L.Control, show: Function, hide: Function }}
*/
addGraticuleControl(map, options = {}) {
const defaultVisible = options.defaultVisible !== false;
const self = this;
let graticuleLayer = null;
let visible = false;
let btnEl = null;
let _onZoom = null;
const _build = () => {
if (graticuleLayer) map.removeLayer(graticuleLayer);
graticuleLayer = self._buildGraticule(map);
graticuleLayer.addTo(map);
};
const show = () => {
visible = true;
_build();
_onZoom = _build;
map.on('zoomend', _onZoom);
if (btnEl) btnEl.classList.add('active');
};
const hide = () => {
visible = false;
if (_onZoom) { map.off('zoomend', _onZoom); _onZoom = null; }
if (graticuleLayer) { map.removeLayer(graticuleLayer); graticuleLayer = null; }
if (btnEl) btnEl.classList.remove('active');
};
const GraticuleControl = L.Control.extend({
options: { position: options.position || 'bottomleft' },
onAdd() {
const btn = L.DomUtil.create('button', 'map-graticule-btn');
btn.type = 'button';
btn.title = 'Toggle coordinate grid';
btn.setAttribute('aria-label', 'Toggle coordinate grid');
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<line x1="0" y1="4.67" x2="14" y2="4.67" stroke="currentColor" stroke-width="1"/>
<line x1="0" y1="9.33" x2="14" y2="9.33" stroke="currentColor" stroke-width="1"/>
<line x1="4.67" y1="0" x2="4.67" y2="14" stroke="currentColor" stroke-width="1"/>
<line x1="9.33" y1="0" x2="9.33" y2="14" stroke="currentColor" stroke-width="1"/>
</svg>`;
btnEl = btn;
L.DomEvent.disableClickPropagation(btn);
L.DomEvent.on(btn, 'click', () => { if (visible) hide(); else show(); });
return btn;
},
onRemove() {
hide();
btnEl = null;
},
});
const control = new GraticuleControl();
control.addTo(map);
if (defaultVisible) show();
return { control, show, hide };
},
/** /**
* Add tactical overlays to a map. * Add tactical overlays to a map.
* *
@@ -129,7 +195,7 @@ const MapUtils = {
* { latlng: [lat,lng] } * { latlng: [lat,lng] }
* @param {Object} [options.hudPanels] * @param {Object} [options.hudPanels]
* { modeName: string, getContactCount: ()=>number, getSdrStatus: ()=>boolean } * { modeName: string, getContactCount: ()=>number, getSdrStatus: ()=>boolean }
* @param {boolean} [options.graticule] * @param {boolean} [options.graticule=true] Pass false to start with grid hidden.
* @param {boolean} [options.scaleBar] * @param {boolean} [options.scaleBar]
* *
* @returns {Object} handles * @returns {Object} handles
@@ -174,32 +240,13 @@ const MapUtils = {
handles.updateCount = hudHandles.updateCount; handles.updateCount = hudHandles.updateCount;
handles.updateStatus = hudHandles.updateStatus; handles.updateStatus = hudHandles.updateStatus;
// --- Graticule --- // --- Graticule toggle control (always added; defaultVisible via options.graticule) ---
let graticuleLayer = null; const grat = this.addGraticuleControl(map, {
const buildGraticule = () => { defaultVisible: options.graticule !== false,
if (graticuleLayer) map.removeLayer(graticuleLayer); });
graticuleLayer = this._buildGraticule(map); handles.showGraticule = grat.show;
graticuleLayer.addTo(map); handles.hideGraticule = grat.hide;
}; cleanupFns.push(() => grat.control.remove());
const removeGraticule = () => {
if (graticuleLayer) { map.removeLayer(graticuleLayer); graticuleLayer = null; }
};
if (options.graticule) {
buildGraticule();
map.on('zoomend', buildGraticule);
cleanupFns.push(() => {
map.off('zoomend', buildGraticule);
removeGraticule();
});
}
handles.showGraticule = () => {
buildGraticule();
map.on('zoomend', buildGraticule);
};
handles.hideGraticule = () => {
map.off('zoomend', buildGraticule);
removeGraticule();
};
handles.removeAll = () => cleanupFns.forEach(fn => fn()); handles.removeAll = () => cleanupFns.forEach(fn => fn());
+1
View File
@@ -213,6 +213,7 @@ const BtLocate = (function() {
flushPendingHeatSync(); flushPendingHeatSync();
scheduleMapStabilization(); scheduleMapStabilization();
}); });
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(map);
} }
// Init RSSI chart canvas // Init RSSI chart canvas
+1
View File
@@ -50,6 +50,7 @@ var DroneMode = (function () {
maxZoom: 19, maxZoom: 19,
}).addTo(_map); }).addTo(_map);
} }
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(_map);
} }
function _connectSSE() { function _connectSSE() {
+2
View File
@@ -330,6 +330,8 @@ const MeshCore = (function () {
Settings.registerMap(_map); Settings.registerMap(_map);
}).catch(e => console.warn('MeshCore: Settings init failed, using fallback tiles:', e)); }).catch(e => console.warn('MeshCore: Settings init failed, using fallback tiles:', e));
} }
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(_map);
} }
function _updateMapMarker(node) { function _updateMapMarker(node) {
+13 -11
View File
@@ -16,9 +16,9 @@ const Meshtastic = (function() {
// Map state // Map state
let meshMap = null; let meshMap = null;
let meshMarkers = {}; // nodeId -> marker let meshMarkers = {}; // nodeId -> marker
let localNodeId = null; let localNodeId = null;
let clickDelegationAttached = false; let clickDelegationAttached = false;
/** /**
* Initialize the Meshtastic mode * Initialize the Meshtastic mode
@@ -33,14 +33,14 @@ const Meshtastic = (function() {
/** /**
* Setup event delegation for dynamically created elements * Setup event delegation for dynamically created elements
*/ */
function setupEventDelegation() { function setupEventDelegation() {
if (clickDelegationAttached) return; if (clickDelegationAttached) return;
clickDelegationAttached = true; clickDelegationAttached = true;
// Handle button clicks in Leaflet popups and elsewhere // Handle button clicks in Leaflet popups and elsewhere
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
const tracerouteBtn = e.target.closest('.mesh-traceroute-btn'); const tracerouteBtn = e.target.closest('.mesh-traceroute-btn');
if (tracerouteBtn) { if (tracerouteBtn) {
const nodeId = tracerouteBtn.dataset.nodeId; const nodeId = tracerouteBtn.dataset.nodeId;
if (nodeId) { if (nodeId) {
sendTraceroute(nodeId); sendTraceroute(nodeId);
@@ -137,6 +137,8 @@ const Meshtastic = (function() {
} }
} }
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(meshMap);
// Handle resize // Handle resize
setTimeout(() => { setTimeout(() => {
if (meshMap) meshMap.invalidateSize(); if (meshMap) meshMap.invalidateSize();
+2
View File
@@ -241,6 +241,8 @@ const SSTV = (function() {
} }
} }
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(issMap);
// Create ISS icon // Create ISS icon
const issIcon = L.divIcon({ const issIcon = L.divIcon({
className: 'sstv-iss-marker', className: 'sstv-iss-marker',
+1 -35
View File
@@ -23,7 +23,6 @@ const WeatherSat = (function() {
let groundMap = null; let groundMap = null;
let groundTrackLayer = null; let groundTrackLayer = null;
let groundOverlayLayer = null; let groundOverlayLayer = null;
let groundGridLayer = null;
let satCrosshairMarker = null; let satCrosshairMarker = null;
let observerMarker = null; let observerMarker = null;
let consoleEntries = []; let consoleEntries = [];
@@ -1086,8 +1085,7 @@ const WeatherSat = (function() {
} }
} }
groundGridLayer = L.layerGroup().addTo(groundMap); if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(groundMap);
addStyledGridOverlay(groundGridLayer);
groundTrackLayer = L.layerGroup().addTo(groundMap); groundTrackLayer = L.layerGroup().addTo(groundMap);
groundOverlayLayer = L.layerGroup().addTo(groundMap); groundOverlayLayer = L.layerGroup().addTo(groundMap);
@@ -1145,38 +1143,6 @@ const WeatherSat = (function() {
return segments; return segments;
} }
/**
* Draw a subtle graticule over the base map for a cyber/wireframe look.
*/
function addStyledGridOverlay(layer) {
if (!layer || typeof L === 'undefined') return;
layer.clearLayers();
for (let lon = -180; lon <= 180; lon += 30) {
const line = [];
for (let lat = -85; lat <= 85; lat += 5) line.push([lat, lon]);
L.polyline(line, {
color: '#4ed2ff',
weight: lon % 60 === 0 ? 1.1 : 0.8,
opacity: lon % 60 === 0 ? 0.2 : 0.12,
interactive: false,
lineCap: 'round',
}).addTo(layer);
}
for (let lat = -75; lat <= 75; lat += 15) {
const line = [];
for (let lon = -180; lon <= 180; lon += 5) line.push([lat, lon]);
L.polyline(line, {
color: '#5be7ff',
weight: lat % 30 === 0 ? 1.1 : 0.8,
opacity: lat % 30 === 0 ? 0.2 : 0.12,
interactive: false,
lineCap: 'round',
}).addTo(layer);
}
}
function clearSatelliteCrosshair() { function clearSatelliteCrosshair() {
if (!groundOverlayLayer || !satCrosshairMarker) return; if (!groundOverlayLayer || !satCrosshairMarker) return;
groundOverlayLayer.removeLayer(satCrosshairMarker); groundOverlayLayer.removeLayer(satCrosshairMarker);
+1
View File
@@ -343,6 +343,7 @@ async function initWebsdrLeaflet(mapEl) {
} }
} }
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(websdrMap);
mapEl.style.background = '#1a1d29'; mapEl.style.background = '#1a1d29';
return true; return true;
} }
+75 -1
View File
@@ -56,6 +56,8 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/pager-directory.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/sensor-dashboard.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-timeline.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-timeline.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/device-cards.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components/device-cards.css') }}">
@@ -809,10 +811,18 @@
<div title="Total Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg></span> <span id="msgCount">0</span></div> <div title="Total Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg></span> <span id="msgCount">0</span></div>
<div title="POCSAG Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span> <span id="pocsagCount">0</span></div> <div title="POCSAG Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span> <span id="pocsagCount">0</span></div>
<div title="FLEX Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span> <span id="flexCount">0</span></div> <div title="FLEX Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span> <span id="flexCount">0</span></div>
<div class="view-toggle-group">
<button class="view-toggle-btn view-toggle-btn--active" id="pagerToggleDir" onclick="PagerDirectory.show()">Directory</button>
<button class="view-toggle-btn" id="pagerToggleFeed" onclick="PagerDirectory.hide()">Feed</button>
</div>
</div> </div>
<div class="stats" id="sensorStats"> <div class="stats" id="sensorStats">
<div title="Unique Sensors"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span> <span id="sensorCount">0</span></div> <div title="Unique Sensors"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span> <span id="sensorCount">0</span></div>
<div title="Device Types"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg></span> <span id="deviceCount">0</span></div> <div title="Device Types"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg></span> <span id="deviceCount">0</span></div>
<div class="view-toggle-group">
<button class="view-toggle-btn view-toggle-btn--active" id="sensorToggleDash" onclick="SensorDashboard.show()">Dashboard</button>
<button class="view-toggle-btn" id="sensorToggleFeed" onclick="SensorDashboard.hide()">Feed</button>
</div>
</div> </div>
<div class="stats" id="wifiStats"> <div class="stats" id="wifiStats">
<div title="Access Points"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg></span> <span id="apCount">0</span></div> <div title="Access Points"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg></span> <span id="apCount">0</span></div>
@@ -3597,6 +3607,19 @@
</div> </div>
</div> </div>
<div id="signalViewWrap">
<div id="pagerDirectoryView" class="pdir-panel" style="display:none;">
<div class="pdir-header">Sources — <span id="pagerDirCount">0</span> active</div>
<div id="pagerDirEntries" class="pdir-entries"></div>
</div>
<div id="sensorDashboardView" class="sdb-view" style="display:none;">
<div id="sensorDashboardGrid" class="sdb-grid"></div>
</div>
<div class="pdir-feed-col">
<div class="pdir-feed-header" id="pagerFeedHeader" style="display:none;">
<span id="pagerFeedLabel">All messages</span>
<button id="pagerClearHighlight" class="pdir-clear-btn" onclick="PagerDirectory.clearHighlight()" style="display:none;">clear highlight</button>
</div>
<div class="output-content signal-feed" id="output"> <div class="output-content signal-feed" id="output">
<div class="placeholder signal-empty-state"> <div class="placeholder signal-empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -3605,6 +3628,8 @@
<p>Configure settings and click "Start Decoding" to begin.</p> <p>Configure settings and click "Start Decoding" to begin.</p>
</div> </div>
</div> </div>
</div><!-- .pdir-feed-col -->
</div><!-- #signalViewWrap -->
<div class="status-bar"> <div class="status-bar">
<div class="status-indicator"> <div class="status-indicator">
@@ -3639,6 +3664,8 @@
<script src="{{ url_for('static', filename='js/components/radio-knob.js') }}"></script> <script src="{{ url_for('static', filename='js/components/radio-knob.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/signal-guess.js') }}"></script> <script src="{{ url_for('static', filename='js/components/signal-guess.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/signal-cards.js') }}"></script> <script src="{{ url_for('static', filename='js/components/signal-cards.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/pager-directory.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/sensor-dashboard.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/signal-timeline.js') }}"></script> <script src="{{ url_for('static', filename='js/components/signal-timeline.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/activity-timeline.js') }}"></script> <script src="{{ url_for('static', filename='js/components/activity-timeline.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/timeline-adapters/rf-adapter.js') }}"></script> <script src="{{ url_for('static', filename='js/components/timeline-adapters/rf-adapter.js') }}"></script>
@@ -4582,6 +4609,31 @@
}); });
} }
if (!window._dashboardHomeBtnBound) {
window._dashboardHomeBtnBound = true;
document.addEventListener('click', (event) => {
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const link = event.target && event.target.closest
? event.target.closest('.nav-dashboard-btn')
: null;
if (!link) return;
try {
const href = new URL(link.href, window.location.href);
if (href.origin !== window.location.origin || href.pathname !== '/') return;
} catch (_) { return; }
event.preventDefault();
stopActiveLocalScansForNavigation();
destroyCurrentMode();
const welcome = document.getElementById('welcomePage');
if (welcome) {
welcome.classList.remove('fade-out');
welcome.style.display = '';
}
window.history.pushState({}, '', '/');
});
}
function postStopRequest(url, timeoutMs = LOCAL_STOP_TIMEOUT_MS) { function postStopRequest(url, timeoutMs = LOCAL_STOP_TIMEOUT_MS) {
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
@@ -4873,8 +4925,17 @@
// Hide the signal feed output for modes that have their own visuals // Hide the signal feed output for modes that have their own visuals
const outputEl = document.getElementById('output'); const outputEl = document.getElementById('output');
const signalViewWrapEl = document.getElementById('signalViewWrap');
const modesWithVisuals = ['satellite', 'sstv', 'weathersat', 'sstv_general', 'wefax', 'aprs', 'wifi', 'bluetooth', 'tscm', 'spystations', 'meshtastic', 'meshcore', 'websdr', 'subghz', 'spaceweather', 'bt_locate', 'wifi_locate', 'waterfall', 'morse', 'meteor', 'system', 'ook', 'radiosonde', 'gps', 'drone']; const modesWithVisuals = ['satellite', 'sstv', 'weathersat', 'sstv_general', 'wefax', 'aprs', 'wifi', 'bluetooth', 'tscm', 'spystations', 'meshtastic', 'meshcore', 'websdr', 'subghz', 'spaceweather', 'bt_locate', 'wifi_locate', 'waterfall', 'morse', 'meteor', 'system', 'ook', 'radiosonde', 'gps', 'drone'];
if (outputEl) outputEl.style.display = modesWithVisuals.includes(mode) ? 'none' : 'block'; if (modesWithVisuals.includes(mode)) {
if (signalViewWrapEl) signalViewWrapEl.style.display = 'none';
if (outputEl) outputEl.style.display = 'none';
} else {
if (signalViewWrapEl) signalViewWrapEl.style.display = '';
if (outputEl) outputEl.style.display = 'block';
}
if (typeof PagerDirectory !== 'undefined') PagerDirectory.applyViewState(mode);
if (typeof SensorDashboard !== 'undefined') SensorDashboard.applyViewState(mode);
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers. // Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) { if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
@@ -5121,6 +5182,13 @@
const mode = getModeFromQuery(); const mode = getModeFromQuery();
if (mode && mode !== currentMode) { if (mode && mode !== currentMode) {
switchMode(mode, { updateUrl: false }); switchMode(mode, { updateUrl: false });
} else if (!mode) {
destroyCurrentMode();
const welcome = document.getElementById('welcomePage');
if (welcome) {
welcome.classList.remove('fade-out');
welcome.style.display = '';
}
} }
}); });
@@ -5767,8 +5835,11 @@
msg.rain_unit = 'mm'; msg.rain_unit = 'mm';
} }
if (data.snr !== undefined) msg.snr = data.snr;
if (data.rssi !== undefined) msg.rssi = data.rssi;
// Create card using SignalCards component // Create card using SignalCards component
const card = SignalCards.createSensorCard(msg); const card = SignalCards.createSensorCard(msg);
if (typeof SensorDashboard !== 'undefined') SensorDashboard.addReading(msg);
output.insertBefore(card, output.firstChild); output.insertBefore(card, output.firstChild);
// Add to activity timeline // Add to activity timeline
@@ -7246,6 +7317,7 @@
// Use SignalCards component to create the message card (auto-detects status) // Use SignalCards component to create the message card (auto-detects status)
const msgEl = SignalCards.createPagerCard(msg); const msgEl = SignalCards.createPagerCard(msg);
if (typeof PagerDirectory !== 'undefined') PagerDirectory.addMessage(msg);
output.insertBefore(msgEl, output.firstChild); output.insertBefore(msgEl, output.firstChild);
// Add to activity timeline // Add to activity timeline
@@ -7343,6 +7415,8 @@
Messages cleared. ${isRunning || isSensorRunning ? 'Waiting for new messages...' : 'Start decoding to receive messages.'} Messages cleared. ${isRunning || isSensorRunning ? 'Waiting for new messages...' : 'Start decoding to receive messages.'}
</div> </div>
`; `;
if (typeof PagerDirectory !== 'undefined') PagerDirectory.reset();
if (typeof SensorDashboard !== 'undefined') SensorDashboard.reset();
msgCount = 0; msgCount = 0;
pocsagCount = 0; pocsagCount = 0;
flexCount = 0; flexCount = 0;
+26 -25
View File
@@ -1,6 +1,7 @@
"""Pytest configuration and fixtures.""" """Pytest configuration and fixtures."""
import contextlib import contextlib
import os
import sqlite3 import sqlite3
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -10,14 +11,15 @@ from app import app as flask_app
from routes import register_blueprints from routes import register_blueprints
@pytest.fixture(scope='session') @pytest.fixture(scope="session")
def app(): def app():
"""Create application for testing.""" """Create application for testing."""
flask_app.config['TESTING'] = True os.environ["INTERCEPT_DISABLE_AUTH"] = "1"
flask_app.config["TESTING"] = True
# Disable CSRF for tests # Disable CSRF for tests
flask_app.config['WTF_CSRF_ENABLED'] = False flask_app.config["WTF_CSRF_ENABLED"] = False
# Register blueprints only if not already registered # Register blueprints only if not already registered
if 'pager' not in flask_app.blueprints: if "pager" not in flask_app.blueprints:
register_blueprints(flask_app) register_blueprints(flask_app)
return flask_app return flask_app
@@ -37,8 +39,7 @@ def mock_subprocess():
mock_subprocess['run'].return_value.stdout = 'output' mock_subprocess['run'].return_value.stdout = 'output'
mock_subprocess['run'].return_value.returncode = 0 mock_subprocess['run'].return_value.returncode = 0
""" """
with patch('subprocess.Popen') as mock_popen, \ with patch("subprocess.Popen") as mock_popen, patch("subprocess.run") as mock_run:
patch('subprocess.run') as mock_run:
mock_process = MagicMock() mock_process = MagicMock()
mock_process.poll.return_value = None mock_process.poll.return_value = None
mock_process.stdout = MagicMock() mock_process.stdout = MagicMock()
@@ -46,14 +47,12 @@ def mock_subprocess():
mock_process.pid = 12345 mock_process.pid = 12345
mock_popen.return_value = mock_process mock_popen.return_value = mock_process
mock_run.return_value = MagicMock( mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
returncode=0, stdout='', stderr=''
)
yield { yield {
'popen': mock_popen, "popen": mock_popen,
'process': mock_process, "process": mock_process,
'run': mock_run, "run": mock_run,
} }
@@ -65,14 +64,16 @@ def mock_sdr_device():
def test_example(mock_sdr_device): def test_example(mock_sdr_device):
device = mock_sdr_device(device_type='rtlsdr', index=0) device = mock_sdr_device(device_type='rtlsdr', index=0)
""" """
def _factory(device_type='rtlsdr', index=0):
def _factory(device_type="rtlsdr", index=0):
device = MagicMock() device = MagicMock()
device.device_type = device_type device.device_type = device_type
device.device_index = index device.device_index = index
device.name = f'Mock {device_type} #{index}' device.name = f"Mock {device_type} #{index}"
device.is_available.return_value = True device.is_available.return_value = True
device.build_command.return_value = ['rtl_fm', '-f', '100M'] device.build_command.return_value = ["rtl_fm", "-f", "100M"]
return device return device
return _factory return _factory
@@ -92,9 +93,9 @@ def mock_app_state():
mock_lock = MagicMock() mock_lock = MagicMock()
patches = { patches = {
'current_process': mock_process, "current_process": mock_process,
'pager_queue': mock_queue, "pager_queue": mock_queue,
'pager_lock': mock_lock, "pager_lock": mock_lock,
} }
originals = {} originals = {}
for attr, value in patches.items(): for attr, value in patches.items():
@@ -102,10 +103,10 @@ def mock_app_state():
setattr(app_module, attr, value) setattr(app_module, attr, value)
yield { yield {
'process': mock_process, "process": mock_process,
'queue': mock_queue, "queue": mock_queue,
'lock': mock_lock, "lock": mock_lock,
'module': app_module, "module": app_module,
} }
for attr, orig in originals.items(): for attr, orig in originals.items():
@@ -119,16 +120,16 @@ def mock_app_state():
@pytest.fixture @pytest.fixture
def mock_check_tool(): def mock_check_tool():
"""Patch check_tool() to return True for all tools.""" """Patch check_tool() to return True for all tools."""
with patch('utils.dependencies.check_tool', return_value=True) as mock: with patch("utils.dependencies.check_tool", return_value=True) as mock:
yield mock yield mock
@pytest.fixture @pytest.fixture
def test_db(tmp_path): def test_db(tmp_path):
"""Provide an isolated in-memory SQLite database for tests.""" """Provide an isolated in-memory SQLite database for tests."""
db_path = tmp_path / 'test.db' db_path = tmp_path / "test.db"
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
conn.execute('PRAGMA journal_mode = WAL') conn.execute("PRAGMA journal_mode = WAL")
yield conn yield conn
conn.close() conn.close()
+28 -9
View File
@@ -1,26 +1,25 @@
"""Tests for main application routes.""" """Tests for main application routes."""
def test_index_page(client): def test_index_page(client):
"""Test that index page loads.""" """Test that index page loads."""
response = client.get('/') response = client.get("/")
assert response.status_code == 200 assert response.status_code == 200
assert b'INTERCEPT' in response.data assert b"INTERCEPT" in response.data
def test_dependencies_endpoint(client): def test_dependencies_endpoint(client):
"""Test dependencies endpoint returns valid JSON.""" """Test dependencies endpoint returns valid JSON."""
response = client.get('/dependencies') response = client.get("/dependencies")
assert response.status_code == 200 assert response.status_code == 200
data = response.get_json() data = response.get_json()
assert 'modes' in data assert "modes" in data
assert 'os' in data assert "os" in data
def test_devices_endpoint(client): def test_devices_endpoint(client):
"""Test devices endpoint returns list.""" """Test devices endpoint returns list."""
response = client.get('/devices') response = client.get("/devices")
assert response.status_code == 200 assert response.status_code == 200
data = response.get_json() data = response.get_json()
assert isinstance(data, list) assert isinstance(data, list)
@@ -28,11 +27,31 @@ def test_devices_endpoint(client):
def test_satellite_dashboard(client): def test_satellite_dashboard(client):
"""Test satellite dashboard loads.""" """Test satellite dashboard loads."""
response = client.get('/satellite/dashboard') response = client.get("/satellite/dashboard")
assert response.status_code == 200 assert response.status_code == 200
def test_adsb_dashboard(client): def test_adsb_dashboard(client):
"""Test ADS-B dashboard loads.""" """Test ADS-B dashboard loads."""
response = client.get('/adsb/dashboard') response = client.get("/adsb/dashboard")
assert response.status_code == 200 assert response.status_code == 200
def test_pager_directory_elements_present(client):
response = client.get("/")
assert b'id="signalViewWrap"' in response.data
assert b'id="pagerDirectoryView"' in response.data
assert b'id="pagerDirEntries"' in response.data
assert b'id="pagerFeedHeader"' in response.data
assert b'id="pagerToggleDir"' in response.data
assert b"pager-directory.css" in response.data
assert b"pager-directory.js" in response.data
def test_sensor_dashboard_elements_present(client):
response = client.get("/")
assert b'id="sensorDashboardView"' in response.data
assert b'id="sensorDashboardGrid"' in response.data
assert b'id="sensorToggleDash"' in response.data
assert b"sensor-dashboard.css" in response.data
assert b"sensor-dashboard.js" in response.data
+64 -60
View File
@@ -12,32 +12,36 @@ from routes.satellite import satellite_bp
def app(): def app():
app = Flask(__name__) app = Flask(__name__)
app.register_blueprint(satellite_bp) app.register_blueprint(satellite_bp)
app.config['TESTING'] = True app.config["TESTING"] = True
return app return app
@pytest.fixture @pytest.fixture
def client(app): def client(app):
return app.test_client() return app.test_client()
def test_predict_passes_invalid_coords(client): def test_predict_passes_invalid_coords(client):
"""Verify that invalid coordinates return a 400 error.""" """Verify that invalid coordinates return a 400 error."""
payload = { payload = {
"latitude": 150.0, # Invalid (>90) "latitude": 150.0, # Invalid (>90)
"longitude": -0.1278 "longitude": -0.1278,
} }
response = client.post('/satellite/predict', json=payload) response = client.post("/satellite/predict", json=payload)
assert response.status_code == 400 assert response.status_code == 400
assert response.json['status'] == 'error' assert response.json["status"] == "error"
def test_fetch_celestrak_invalid_category(client): def test_fetch_celestrak_invalid_category(client):
"""Verify that an unauthorized category is rejected.""" """Verify that an unauthorized category is rejected."""
response = client.get('/satellite/celestrak/category_fake') response = client.get("/satellite/celestrak/category_fake")
assert response.status_code == 400 assert response.status_code == 400
assert response.json['status'] == 'error' assert response.json["status"] == "error"
assert 'Invalid category' in response.json['message'] assert "Invalid category" in response.json["message"]
# Mocking Tests (External Calls and Skyfield) # Mocking Tests (External Calls and Skyfield)
@patch('urllib.request.urlopen') @patch("urllib.request.urlopen")
def test_update_tle_success(mock_urlopen, client): def test_update_tle_success(mock_urlopen, client):
"""Simulate a successful response from CelesTrak.""" """Simulate a successful response from CelesTrak."""
mock_content = ( mock_content = (
@@ -51,26 +55,24 @@ def test_update_tle_success(mock_urlopen, client):
mock_response.__enter__.return_value = mock_response mock_response.__enter__.return_value = mock_response
mock_urlopen.return_value = mock_response mock_urlopen.return_value = mock_response
response = client.post('/satellite/update-tle') response = client.post("/satellite/update-tle")
assert response.status_code == 200 assert response.status_code == 200
assert response.json['status'] == 'success' assert response.json["status"] == "success"
assert 'ISS' in response.json['updated'] assert "ISS" in response.json["updated"]
@patch('skyfield.api.load')
@patch("skyfield.api.load")
def test_get_satellite_position_skyfield_error(mock_load, client): def test_get_satellite_position_skyfield_error(mock_load, client):
"""Test behavior when Skyfield fails or data is missing.""" """Test behavior when Skyfield fails or data is missing."""
# Force the timescale load to fail # Force the timescale load to fail
mock_load.side_effect = Exception("Skyfield error") mock_load.side_effect = Exception("Skyfield error")
payload = { payload = {"latitude": 51.5, "longitude": -0.1, "satellites": ["ISS"]}
"latitude": 51.5, response = client.post("/satellite/position", json=payload)
"longitude": -0.1,
"satellites": ["ISS"]
}
response = client.post('/satellite/position', json=payload)
# Should return success but an empty positions list due to internal try-except # Should return success but an empty positions list due to internal try-except
assert response.status_code == 200 assert response.status_code == 200
assert response.json['positions'] == [] assert response.json["positions"] == []
def test_tracker_position_has_no_observer_fields(): def test_tracker_position_has_no_observer_fields():
"""SSE tracker positions must NOT include observer-relative fields. """SSE tracker positions must NOT include observer-relative fields.
@@ -83,9 +85,9 @@ def test_tracker_position_has_no_observer_fields():
from routes.satellite import _start_satellite_tracker from routes.satellite import _start_satellite_tracker
ISS_TLE = ( ISS_TLE = (
'ISS (ZARYA)', "ISS (ZARYA)",
'1 25544U 98067A 24001.00000000 .00016717 00000-0 30171-3 0 9993', "1 25544U 98067A 24001.00000000 .00016717 00000-0 30171-3 0 9993",
'2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123457', "2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123457",
) )
sat_q = queue.Queue(maxsize=5) sat_q = queue.Queue(maxsize=5)
@@ -93,54 +95,61 @@ def test_tracker_position_has_no_observer_fields():
mock_app.satellite_queue = sat_q mock_app.satellite_queue = sat_q
from skyfield.api import load as _real_load from skyfield.api import load as _real_load
real_ts = _real_load.timescale(builtin=True) real_ts = _real_load.timescale(builtin=True)
# Pre-populate track cache so the tracker loop doesn't block computing 90 points # Pre-populate track cache so the tracker loop doesn't block computing 90 points
tle_key = (ISS_TLE[0], ISS_TLE[1][:20]) tle_key = (ISS_TLE[0], ISS_TLE[1][:20])
stub_track = [{'lat': 0.0, 'lon': float(i), 'past': i < 45} for i in range(91)] stub_track = [{"lat": 0.0, "lon": float(i), "past": i < 45} for i in range(91)]
with patch('routes.satellite._tle_cache', {'ISS': ISS_TLE}), \ with (
patch('routes.satellite.get_tracked_satellites') as mock_tracked, \ patch("routes.satellite._tle_cache", {"ISS": ISS_TLE}),
patch('routes.satellite._track_cache', {tle_key: (stub_track, 1e18)}), \ patch("routes.satellite.get_tracked_satellites") as mock_tracked,
patch('routes.satellite._get_timescale', return_value=real_ts), \ patch("routes.satellite._track_cache", {tle_key: (stub_track, 1e18)}),
patch.dict('sys.modules', {'app': mock_app}): patch("routes.satellite._get_timescale", return_value=real_ts),
mock_tracked.return_value = [{ patch.dict("sys.modules", {"app": mock_app}),
'name': 'ISS (ZARYA)', 'norad_id': 25544, ):
'tle_line1': ISS_TLE[1], 'tle_line2': ISS_TLE[2], mock_tracked.return_value = [
}] {
"name": "ISS (ZARYA)",
"norad_id": 25544,
"tle_line1": ISS_TLE[1],
"tle_line2": ISS_TLE[2],
}
]
t = threading.Thread(target=_start_satellite_tracker, daemon=True) t = threading.Thread(target=_start_satellite_tracker, daemon=True)
t.start() t.start()
msg = sat_q.get(timeout=10) msg = sat_q.get(timeout=10)
assert msg['type'] == 'positions' assert msg["type"] == "positions"
pos = msg['positions'][0] pos = msg["positions"][0]
for forbidden in ('elevation', 'azimuth', 'distance', 'visible'): for forbidden in ("elevation", "azimuth", "distance", "visible"):
assert forbidden not in pos, f"SSE tracker must not emit '{forbidden}'" assert forbidden not in pos, f"SSE tracker must not emit '{forbidden}'"
for required in ('lat', 'lon', 'altitude', 'satellite', 'norad_id'): for required in ("lat", "lon", "altitude", "satellite", "norad_id"):
assert required in pos, f"SSE tracker must emit '{required}'" assert required in pos, f"SSE tracker must emit '{required}'"
def test_predict_passes_currentpos_has_full_fields(client): def test_predict_passes_currentpos_has_full_fields(client):
"""currentPos in pass results must include altitude, elevation, azimuth, distance.""" """currentPos in pass results must include altitude, elevation, azimuth, distance."""
payload = { payload = {
'latitude': 51.5074, "latitude": 51.5074,
'longitude': -0.1278, "longitude": -0.1278,
'hours': 48, "hours": 2,
'minEl': 5, "minEl": 5,
'satellites': ['ISS'], "satellites": ["ISS"],
} }
response = client.post('/satellite/predict', json=payload) response = client.post("/satellite/predict", json=payload)
assert response.status_code == 200 assert response.status_code == 200
data = response.json data = response.json
assert data['status'] == 'success' assert data["status"] == "success"
if data['passes']: if data["passes"]:
cp = data['passes'][0].get('currentPos', {}) cp = data["passes"][0].get("currentPos", {})
for field in ('lat', 'lon', 'altitude', 'elevation', 'azimuth', 'distance'): for field in ("lat", "lon", "altitude", "elevation", "azimuth", "distance"):
assert field in cp, f"currentPos missing field: {field}" assert field in cp, f"currentPos missing field: {field}"
@patch('routes.satellite.refresh_tle_data', return_value=['ISS']) @patch("routes.satellite.refresh_tle_data", return_value=["ISS"])
@patch('routes.satellite._load_db_satellites_into_cache') @patch("routes.satellite._load_db_satellites_into_cache")
def test_tle_auto_refresh_schedules_daily_repeat(mock_load_db, mock_refresh): def test_tle_auto_refresh_schedules_daily_repeat(mock_load_db, mock_refresh):
"""After the first TLE refresh, a 24-hour follow-up timer must be scheduled.""" """After the first TLE refresh, a 24-hour follow-up timer must be scheduled."""
import threading as real_threading import threading as real_threading
@@ -158,28 +167,23 @@ def test_tle_auto_refresh_schedules_daily_repeat(mock_load_db, mock_refresh):
if self._delay <= 5: if self._delay <= 5:
self._fn() self._fn()
with patch('routes.satellite.threading') as mock_threading: with patch("routes.satellite.threading") as mock_threading:
mock_threading.Timer = CapturingTimer mock_threading.Timer = CapturingTimer
mock_threading.Thread = real_threading.Thread mock_threading.Thread = real_threading.Thread
from routes.satellite import init_tle_auto_refresh from routes.satellite import init_tle_auto_refresh
init_tle_auto_refresh() init_tle_auto_refresh()
# First timer: startup delay (≤5s); second timer: 24h repeat (≥86400s) # First timer: startup delay (≤5s); second timer: 24h repeat (≥86400s)
assert any(d <= 5 for d in scheduled_delays), \ assert any(d <= 5 for d in scheduled_delays), f"Expected startup delay timer; got delays: {scheduled_delays}"
f"Expected startup delay timer; got delays: {scheduled_delays}" assert any(d >= 86400 for d in scheduled_delays), f"Expected ~24h repeat timer; got delays: {scheduled_delays}"
assert any(d >= 86400 for d in scheduled_delays), \
f"Expected ~24h repeat timer; got delays: {scheduled_delays}"
# Logic Integration Test (Simulating prediction) # Logic Integration Test (Simulating prediction)
def test_predict_passes_empty_cache(client): def test_predict_passes_empty_cache(client):
"""Verify that if the satellite is not in cache, no passes are returned.""" """Verify that if the satellite is not in cache, no passes are returned."""
payload = { payload = {"latitude": 51.5, "longitude": -0.1, "satellites": ["SATELLITE_NON_EXISTENT"]}
"latitude": 51.5, response = client.post("/satellite/predict", json=payload)
"longitude": -0.1,
"satellites": ["SATELLITE_NON_EXISTENT"]
}
response = client.post('/satellite/predict', json=payload)
assert response.status_code == 200 assert response.status_code == 200
assert len(response.json['passes']) == 0 assert len(response.json["passes"]) == 0
+19 -24
View File
@@ -30,12 +30,15 @@ class HeuristicsEngine:
- has_random_address: Uses privacy-preserving random address - has_random_address: Uses privacy-preserving random address
""" """
def evaluate(self, device: BTDeviceAggregate) -> None: def evaluate(self, device: BTDeviceAggregate) -> BTDeviceAggregate:
""" """
Evaluate all heuristics for a device and update its flags. Evaluate all heuristics for a device and update its flags.
Args: Args:
device: The BTDeviceAggregate to evaluate. device: The BTDeviceAggregate to evaluate.
Returns:
The same device instance with updated heuristic flags.
""" """
# Note: is_new and has_random_address are set by the aggregator # Note: is_new and has_random_address are set by the aggregator
# Here we evaluate the behavioral heuristics # Here we evaluate the behavioral heuristics
@@ -43,6 +46,7 @@ class HeuristicsEngine:
device.is_persistent = self._check_persistent(device) device.is_persistent = self._check_persistent(device)
device.is_beacon_like = self._check_beacon_like(device) device.is_beacon_like = self._check_beacon_like(device)
device.is_strong_stable = self._check_strong_stable(device) device.is_strong_stable = self._check_strong_stable(device)
return device
def _check_persistent(self, device: BTDeviceAggregate) -> bool: def _check_persistent(self, device: BTDeviceAggregate) -> bool:
""" """
@@ -134,45 +138,36 @@ class HeuristicsEngine:
Returns: Returns:
Dictionary with heuristic flags and explanations. Dictionary with heuristic flags and explanations.
""" """
summary = { summary = {"flags": [], "details": {}}
'flags': [],
'details': {}
}
if device.is_new: if device.is_new:
summary['flags'].append('new') summary["flags"].append("new")
summary['details']['new'] = 'Device appeared after baseline was set' summary["details"]["new"] = "Device appeared after baseline was set"
if device.is_persistent: if device.is_persistent:
summary['flags'].append('persistent') summary["flags"].append("persistent")
summary['details']['persistent'] = ( summary["details"]["persistent"] = (
f'Seen {device.seen_count} times over ' f"Seen {device.seen_count} times over {device.duration_seconds:.0f}s ({device.seen_rate:.1f}/min)"
f'{device.duration_seconds:.0f}s ({device.seen_rate:.1f}/min)'
) )
if device.is_beacon_like: if device.is_beacon_like:
summary['flags'].append('beacon_like') summary["flags"].append("beacon_like")
intervals = self._calculate_intervals(device) intervals = self._calculate_intervals(device)
if intervals: if intervals:
mean_int = statistics.mean(intervals) mean_int = statistics.mean(intervals)
summary['details']['beacon_like'] = ( summary["details"]["beacon_like"] = f"Regular advertising interval (~{mean_int:.1f}s)"
f'Regular advertising interval (~{mean_int:.1f}s)'
)
else: else:
summary['details']['beacon_like'] = 'Regular advertising pattern' summary["details"]["beacon_like"] = "Regular advertising pattern"
if device.is_strong_stable: if device.is_strong_stable:
summary['flags'].append('strong_stable') summary["flags"].append("strong_stable")
summary['details']['strong_stable'] = ( summary["details"]["strong_stable"] = (
f'Strong signal ({device.rssi_median:.0f} dBm) ' f"Strong signal ({device.rssi_median:.0f} dBm) with low variance ({device.rssi_variance:.1f})"
f'with low variance ({device.rssi_variance:.1f})'
) )
if device.has_random_address: if device.has_random_address:
summary['flags'].append('random_address') summary["flags"].append("random_address")
summary['details']['random_address'] = ( summary["details"]["random_address"] = f"Uses {device.address_type} address (privacy-preserving)"
f'Uses {device.address_type} address (privacy-preserving)'
)
return summary return summary