mirror of
https://github.com/smittix/intercept.git
synced 2026-06-12 07:53:30 -07:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b68a53eb53 | |||
| d68d1ec53a | |||
| 9c15ece508 | |||
| fe222c0393 | |||
| 68cafe8cd0 | |||
| d01742678c | |||
| 31ae70b8fa | |||
| e7f13a5856 | |||
| a9ed367148 | |||
| 2505218385 | |||
| b5c35890af | |||
| 484d9ce21b | |||
| 9353527e1b | |||
| fd3ad63971 | |||
| 2e583649d0 | |||
| a3c509aa94 | |||
| f26a820b1d | |||
| 901e7f95e8 | |||
| 592d11aae2 | |||
| 30a0085f1d | |||
| b30d883974 |
@@ -15,12 +15,21 @@ jobs:
|
|||||||
|
|
||||||
test:
|
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
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Signal View Wrap — flex container for split-panel layouts
|
||||||
|
============================================================ */
|
||||||
|
#signalViewWrap {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Feed column — wraps feed header + #output, fills remaining space */
|
||||||
|
.pdir-feed-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Feed header strip — shown in directory mode above the message list */
|
||||||
|
.pdir-feed-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdir-clear-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.pdir-clear-btn:hover { color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* ---- Directory panel (left side of split) ---- */
|
||||||
|
.pdir-panel {
|
||||||
|
display: flex;
|
||||||
|
width: 200px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdir-header {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-card);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdir-entries {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Individual address entry ---- */
|
||||||
|
.pdir-entry {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-bottom: 1px solid rgba(var(--accent-cyan-rgb), 0.04);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
.pdir-entry:hover { background: var(--bg-tertiary); }
|
||||||
|
.pdir-entry--active {
|
||||||
|
background: rgba(var(--accent-cyan-rgb), 0.06);
|
||||||
|
border-left: 2px solid var(--accent-cyan);
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdir-entry-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdir-proto {
|
||||||
|
font-size: 8px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pdir-proto--p { background: rgba(var(--accent-cyan-rgb), 0.15); color: var(--accent-cyan); }
|
||||||
|
.pdir-proto--f { background: rgba(var(--accent-purple-rgb), 0.15); color: var(--accent-purple); }
|
||||||
|
|
||||||
|
.pdir-addr {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdir-new-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-green);
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.pdir-new-dot--active {
|
||||||
|
animation: pdir-dot-fade 3s ease-out forwards;
|
||||||
|
}
|
||||||
|
@keyframes pdir-dot-fade {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
85% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdir-count { font-size: 9px; color: var(--text-muted); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.pdir-bar-wrap { height: 2px; background: var(--bg-tertiary); border-radius: 1px; margin-bottom: 2px; }
|
||||||
|
.pdir-bar { height: 2px; background: var(--accent-cyan); border-radius: 1px; transition: width var(--transition-slow); }
|
||||||
|
.pdir-bar--flex { background: var(--accent-purple); }
|
||||||
|
|
||||||
|
.pdir-age { font-size: 8px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ---- Highlight applied to signal-cards in #output ---- */
|
||||||
|
.signal-card.pdir-hl {
|
||||||
|
border-left: 2px solid var(--accent-cyan) !important;
|
||||||
|
background: rgba(var(--accent-cyan-rgb), 0.04) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- View toggle button group (inside .stats) ---- */
|
||||||
|
.stats .view-toggle-group { display: none; }
|
||||||
|
.stats.active .view-toggle-group { display: flex; }
|
||||||
|
|
||||||
|
.view-toggle-group {
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 2px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle-btn {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.view-toggle-btn:hover { color: var(--text-dim); }
|
||||||
|
.view-toggle-btn--active {
|
||||||
|
background: var(--accent-cyan-dim);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Sensor Dashboard View
|
||||||
|
============================================================ */
|
||||||
|
.sdb-view {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdb-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Station card ---- */
|
||||||
|
.sdb-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdb-card--new {
|
||||||
|
border-color: rgba(var(--accent-green-rgb), 0.3);
|
||||||
|
animation: sdb-slide-in 0.4s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes sdb-slide-in {
|
||||||
|
from { opacity: 0; transform: translateY(-6px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdb-card--flash-blue {
|
||||||
|
animation: sdb-flash-blue 0.8s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes sdb-flash-blue {
|
||||||
|
0% { background: rgba(var(--accent-cyan-rgb), 0.10); border-color: rgba(var(--accent-cyan-rgb), 0.30); }
|
||||||
|
100% { background: var(--bg-card); border-color: var(--border-color); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdb-card--flash-purple {
|
||||||
|
animation: sdb-flash-purple 0.8s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes sdb-flash-purple {
|
||||||
|
0% { background: rgba(var(--accent-purple-rgb), 0.10); border-color: rgba(var(--accent-purple-rgb), 0.30); }
|
||||||
|
100% { background: var(--bg-card); border-color: var(--border-color); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Card header ---- */
|
||||||
|
.sdb-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.sdb-name {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
.sdb-id { font-size: 8px; color: var(--text-muted); margin-top: 1px; }
|
||||||
|
.sdb-age { font-size: 8px; color: var(--text-muted); white-space: nowrap; }
|
||||||
|
.sdb-age--fresh { color: var(--accent-green); }
|
||||||
|
|
||||||
|
/* ---- Readings grid ---- */
|
||||||
|
.sdb-readings {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
min-height: 36px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.sdb-reading { text-align: center; min-width: 34px; }
|
||||||
|
.sdb-reading-val { font-size: 15px; font-weight: var(--font-bold); line-height: 1; }
|
||||||
|
.sdb-reading-unit { font-size: 8px; color: var(--text-muted); }
|
||||||
|
.sdb-reading-label { font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 1px; }
|
||||||
|
.sdb-no-readings { font-size: 9px; color: var(--text-muted); align-self: center; }
|
||||||
|
|
||||||
|
/* ---- State-only device ---- */
|
||||||
|
.sdb-state { display: flex; align-items: center; gap: 6px; min-height: 36px; }
|
||||||
|
.sdb-state-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.sdb-state-dot--on { background: var(--accent-green); box-shadow: 0 0 5px var(--accent-green); }
|
||||||
|
.sdb-state-dot--off { background: var(--text-muted); }
|
||||||
|
.sdb-state-label { font-size: 9px; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ---- Sparkline ---- */
|
||||||
|
.sdb-spark { margin-bottom: 6px; }
|
||||||
|
.sdb-spark svg { width: 100%; height: 22px; display: block; }
|
||||||
|
.sdb-spark-placeholder {
|
||||||
|
height: 22px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Card footer ---- */
|
||||||
|
.sdb-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
.sdb-bat--ok { color: var(--accent-green); }
|
||||||
|
.sdb-bat--low { color: var(--accent-red); }
|
||||||
|
.sdb-snr { color: var(--text-muted); }
|
||||||
|
.sdb-freq {
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
@@ -106,6 +106,36 @@
|
|||||||
white-space: nowrap;
|
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. */
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
const PagerDirectory = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'pagerView';
|
||||||
|
|
||||||
|
// Map<address, { count, protocol, lastSeen }>
|
||||||
|
const addresses = new Map();
|
||||||
|
let highlighted = null;
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAge(ts) {
|
||||||
|
const s = Math.floor((Date.now() - ts) / 1000);
|
||||||
|
if (s < 10) return 'just now';
|
||||||
|
if (s < 60) return `${s}s ago`;
|
||||||
|
return `${Math.floor(s / 60)}m ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Directory rendering ----
|
||||||
|
|
||||||
|
function renderDirectory() {
|
||||||
|
const entriesEl = document.getElementById('pagerDirEntries');
|
||||||
|
const countEl = document.getElementById('pagerDirCount');
|
||||||
|
if (!entriesEl) return;
|
||||||
|
|
||||||
|
const sorted = [...addresses.entries()].sort((a, b) => b[1].count - a[1].count);
|
||||||
|
const maxCount = sorted.length > 0 ? sorted[0][1].count : 1;
|
||||||
|
|
||||||
|
if (countEl) countEl.textContent = sorted.length;
|
||||||
|
|
||||||
|
sorted.forEach(([addr, data]) => {
|
||||||
|
let el = entriesEl.querySelector(`[data-pdir-addr="${CSS.escape(addr)}"]`);
|
||||||
|
const isActive = addr === highlighted;
|
||||||
|
const pct = Math.round((data.count / maxCount) * 100);
|
||||||
|
const isPocsag = data.protocol !== 'flex';
|
||||||
|
const protoClass = isPocsag ? 'pdir-proto--p' : 'pdir-proto--f';
|
||||||
|
const barClass = isPocsag ? '' : 'pdir-bar--flex';
|
||||||
|
const html = `
|
||||||
|
<div class="pdir-entry-top">
|
||||||
|
<span class="pdir-proto ${protoClass}">${isPocsag ? 'P' : 'F'}</span>
|
||||||
|
<span class="pdir-addr">${esc(addr)}</span>
|
||||||
|
<span class="pdir-new-dot"></span>
|
||||||
|
<span class="pdir-count">×${data.count}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pdir-bar-wrap"><div class="pdir-bar ${barClass}" style="width:${pct}%"></div></div>
|
||||||
|
<div class="pdir-age">${formatAge(data.lastSeen)}</div>`;
|
||||||
|
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('div');
|
||||||
|
el.className = 'pdir-entry';
|
||||||
|
el.dataset.pdirAddr = addr;
|
||||||
|
el.addEventListener('click', () => toggleHighlight(addr));
|
||||||
|
entriesEl.appendChild(el);
|
||||||
|
}
|
||||||
|
el.classList.toggle('pdir-entry--active', isActive);
|
||||||
|
el.innerHTML = html;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-order DOM to match sort
|
||||||
|
sorted.forEach(([addr]) => {
|
||||||
|
const el = entriesEl.querySelector(`[data-pdir-addr="${CSS.escape(addr)}"]`);
|
||||||
|
if (el) entriesEl.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashNewDot(addr) {
|
||||||
|
// Find the dot inside this entry after the current render frame
|
||||||
|
setTimeout(() => {
|
||||||
|
const entriesEl = document.getElementById('pagerDirEntries');
|
||||||
|
const entry = entriesEl?.querySelector(`[data-pdir-addr="${CSS.escape(addr)}"]`);
|
||||||
|
const dot = entry?.querySelector('.pdir-new-dot');
|
||||||
|
if (!dot) return;
|
||||||
|
dot.classList.remove('pdir-new-dot--active');
|
||||||
|
void dot.offsetWidth; // force reflow to restart animation
|
||||||
|
dot.classList.add('pdir-new-dot--active');
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Highlight ----
|
||||||
|
|
||||||
|
function toggleHighlight(addr) {
|
||||||
|
if (highlighted === addr) clearHighlight();
|
||||||
|
else highlight(addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlight(addr) {
|
||||||
|
highlighted = addr;
|
||||||
|
renderDirectory();
|
||||||
|
|
||||||
|
const feedLabel = document.getElementById('pagerFeedLabel');
|
||||||
|
const clearBtn = document.getElementById('pagerClearHighlight');
|
||||||
|
if (feedLabel) feedLabel.textContent = `${addr} highlighted`;
|
||||||
|
if (clearBtn) clearBtn.style.display = 'inline';
|
||||||
|
|
||||||
|
const output = document.getElementById('output');
|
||||||
|
if (!output) return;
|
||||||
|
|
||||||
|
output.querySelectorAll('.signal-card').forEach(card => {
|
||||||
|
card.classList.toggle('pdir-hl', card.dataset.address === addr);
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = output.querySelector(`.signal-card[data-address="${CSS.escape(addr)}"]`);
|
||||||
|
if (first) first.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHighlight() {
|
||||||
|
highlighted = null;
|
||||||
|
renderDirectory();
|
||||||
|
|
||||||
|
const feedLabel = document.getElementById('pagerFeedLabel');
|
||||||
|
const clearBtn = document.getElementById('pagerClearHighlight');
|
||||||
|
if (feedLabel) feedLabel.textContent = 'All messages';
|
||||||
|
if (clearBtn) clearBtn.style.display = 'none';
|
||||||
|
|
||||||
|
document.getElementById('output')
|
||||||
|
?.querySelectorAll('.pdir-hl')
|
||||||
|
.forEach(c => c.classList.remove('pdir-hl'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Public: message hook ----
|
||||||
|
|
||||||
|
function addMessage(msg) {
|
||||||
|
const addr = msg.address;
|
||||||
|
if (!addr) return;
|
||||||
|
const proto = (msg.protocol || '').includes('FLEX') ? 'flex' : 'pocsag';
|
||||||
|
const entry = addresses.get(addr);
|
||||||
|
if (entry) {
|
||||||
|
entry.count++;
|
||||||
|
entry.lastSeen = Date.now();
|
||||||
|
entry.protocol = proto;
|
||||||
|
} else {
|
||||||
|
addresses.set(addr, { count: 1, protocol: proto, lastSeen: Date.now() });
|
||||||
|
}
|
||||||
|
renderDirectory();
|
||||||
|
flashNewDot(addr);
|
||||||
|
// Re-apply highlight class to the newly inserted card (caller inserts it after this hook)
|
||||||
|
if (highlighted === addr) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const output = document.getElementById('output');
|
||||||
|
output?.querySelectorAll(`.signal-card[data-address="${CSS.escape(addr)}"]`)
|
||||||
|
.forEach(c => c.classList.add('pdir-hl'));
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Show / hide / reset ----
|
||||||
|
|
||||||
|
function applyViewState(mode) {
|
||||||
|
const dirPanel = document.getElementById('pagerDirectoryView');
|
||||||
|
const feedHeader = document.getElementById('pagerFeedHeader');
|
||||||
|
|
||||||
|
if (mode === 'pager') {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY) || 'directory';
|
||||||
|
const isDir = saved === 'directory';
|
||||||
|
if (dirPanel) dirPanel.style.display = isDir ? 'flex' : 'none';
|
||||||
|
if (feedHeader) feedHeader.style.display = isDir ? 'flex' : 'none';
|
||||||
|
_updateToggle(isDir);
|
||||||
|
renderDirectory();
|
||||||
|
} else {
|
||||||
|
if (dirPanel) dirPanel.style.display = 'none';
|
||||||
|
if (feedHeader) feedHeader.style.display = 'none';
|
||||||
|
clearHighlight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
localStorage.setItem(STORAGE_KEY, 'directory');
|
||||||
|
applyViewState('pager');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
localStorage.setItem(STORAGE_KEY, 'feed');
|
||||||
|
applyViewState('pager');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateToggle(isDir) {
|
||||||
|
document.getElementById('pagerToggleDir')?.classList.toggle('view-toggle-btn--active', isDir);
|
||||||
|
document.getElementById('pagerToggleFeed')?.classList.toggle('view-toggle-btn--active', !isDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
addresses.clear();
|
||||||
|
highlighted = null;
|
||||||
|
const entriesEl = document.getElementById('pagerDirEntries');
|
||||||
|
const countEl = document.getElementById('pagerDirCount');
|
||||||
|
if (entriesEl) entriesEl.innerHTML = '';
|
||||||
|
if (countEl) countEl.textContent = '0';
|
||||||
|
clearHighlight();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { addMessage, highlight, clearHighlight, show, hide, reset, applyViewState };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
const SensorDashboard = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'sensorView';
|
||||||
|
const MAX_SPARK_PTS = 30;
|
||||||
|
|
||||||
|
// Map<deviceKey, { card: HTMLElement, history: number[], primaryColor: string }>
|
||||||
|
const devices = new Map();
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAge(timestamp) {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
const ts = typeof timestamp === 'string' ? new Date(timestamp).getTime() : Number(timestamp);
|
||||||
|
const s = Math.floor((Date.now() - ts) / 1000);
|
||||||
|
if (s < 10) return 'just now';
|
||||||
|
if (s < 60) return `${s}s ago`;
|
||||||
|
return `${Math.floor(s / 60)}m ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecent(timestamp) {
|
||||||
|
if (!timestamp) return false;
|
||||||
|
const ts = typeof timestamp === 'string' ? new Date(timestamp).getTime() : Number(timestamp);
|
||||||
|
return (Date.now() - ts) < 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Primary value for sparkline ----
|
||||||
|
|
||||||
|
function getPrimary(msg) {
|
||||||
|
if (msg.temperature !== undefined)
|
||||||
|
return { value: msg.temperature, color: '#f59e0b' };
|
||||||
|
if (msg.pressure !== undefined)
|
||||||
|
return { value: msg.pressure, color: '#a78bfa' };
|
||||||
|
if (msg.wind_speed !== undefined)
|
||||||
|
return { value: msg.wind_speed, color: '#4aa3ff' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFlashClass(msg) {
|
||||||
|
return msg.temperature !== undefined ? 'sdb-card--flash-blue' : 'sdb-card--flash-purple';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- HTML builders ----
|
||||||
|
|
||||||
|
function buildReadingsHTML(msg) {
|
||||||
|
// State-only device (no continuous numeric field)
|
||||||
|
if (msg.state !== undefined && msg.temperature === undefined
|
||||||
|
&& msg.pressure === undefined && msg.wind_speed === undefined) {
|
||||||
|
const raw = String(msg.state);
|
||||||
|
const isOn = raw === '1' || raw === 'true' || raw === 'on' || raw === 'active';
|
||||||
|
return `<div class="sdb-state">
|
||||||
|
<span class="sdb-state-dot ${isOn ? 'sdb-state-dot--on' : 'sdb-state-dot--off'}"></span>
|
||||||
|
<span class="sdb-state-label">${esc(raw.toUpperCase())}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (msg.temperature !== undefined)
|
||||||
|
parts.push({ val: msg.temperature, unit: `°${msg.temperature_unit || 'C'}`, label: 'Temp', color: '#f59e0b' });
|
||||||
|
if (msg.humidity !== undefined)
|
||||||
|
parts.push({ val: msg.humidity, unit: '%', label: 'Humid', color: '#38bdf8' });
|
||||||
|
if (msg.pressure !== undefined)
|
||||||
|
parts.push({ val: msg.pressure, unit: msg.pressure_unit || 'hPa', label: 'Press', color: '#a78bfa' });
|
||||||
|
if (msg.wind_speed !== undefined)
|
||||||
|
parts.push({ val: msg.wind_speed, unit: msg.wind_unit || 'km/h', label: 'Wind', color: '#4aa3ff' });
|
||||||
|
if (msg.rain !== undefined)
|
||||||
|
parts.push({ val: msg.rain, unit: msg.rain_unit || 'mm', label: 'Rain', color: '#38bdf8' });
|
||||||
|
|
||||||
|
if (parts.length === 0)
|
||||||
|
return `<div class="sdb-no-readings">No numeric data</div>`;
|
||||||
|
|
||||||
|
return parts.map(p => `
|
||||||
|
<div class="sdb-reading">
|
||||||
|
<div class="sdb-reading-val" style="color:${p.color}">${esc(String(p.val))}</div>
|
||||||
|
<div class="sdb-reading-unit">${esc(p.unit)}</div>
|
||||||
|
<div class="sdb-reading-label">${p.label}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSparklineHTML(history, color) {
|
||||||
|
if (history.length < 2)
|
||||||
|
return `<div class="sdb-spark-placeholder">Collecting data…</div>`;
|
||||||
|
|
||||||
|
const W = 120, H = 22, PAD = 2;
|
||||||
|
const min = Math.min(...history);
|
||||||
|
const max = Math.max(...history);
|
||||||
|
const range = max - min || 1;
|
||||||
|
const pts = history.map((v, i) => {
|
||||||
|
const x = (i / (history.length - 1)) * (W - PAD * 2) + PAD;
|
||||||
|
const y = H - PAD - ((v - min) / range) * (H - PAD * 2);
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
}).join(' ');
|
||||||
|
const last = pts.split(' ').pop().split(',');
|
||||||
|
return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect fill="var(--bg-secondary)" width="${W}" height="${H}"/>
|
||||||
|
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5" opacity="0.85"/>
|
||||||
|
<circle cx="${last[0]}" cy="${last[1]}" r="2" fill="${color}"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCardHTML(msg, history, primaryColor) {
|
||||||
|
const age = formatAge(msg.timestamp);
|
||||||
|
const fresh = isRecent(msg.timestamp);
|
||||||
|
const batLow = msg.battery === 'LOW';
|
||||||
|
const sparkHTML = history.length > 0
|
||||||
|
? buildSparklineHTML(history, primaryColor || '#4aa3ff')
|
||||||
|
: `<div class="sdb-spark-placeholder">Waiting for data…</div>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="sdb-card-header">
|
||||||
|
<div>
|
||||||
|
<div class="sdb-name">${esc(msg.model || 'Unknown')}</div>
|
||||||
|
<div class="sdb-id">ID ${esc(String(msg.id || 'N/A'))}${msg.channel ? ` · Ch ${esc(String(msg.channel))}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sdb-age${fresh ? ' sdb-age--fresh' : ''}">${age}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sdb-readings">${buildReadingsHTML(msg)}</div>
|
||||||
|
<div class="sdb-spark">${sparkHTML}</div>
|
||||||
|
<div class="sdb-footer">
|
||||||
|
${msg.battery ? `<span class="sdb-bat ${batLow ? 'sdb-bat--low' : 'sdb-bat--ok'}">● BAT ${esc(msg.battery)}</span>` : '<span></span>'}
|
||||||
|
${msg.snr !== undefined ? `<span class="sdb-snr">SNR ${esc(String(msg.snr))} dB</span>` : '<span></span>'}
|
||||||
|
${msg.frequency ? `<span class="sdb-freq">${esc(String(msg.frequency))}</span>` : '<span></span>'}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Public: reading hook ----
|
||||||
|
|
||||||
|
function addReading(msg) {
|
||||||
|
const key = `${msg.model || 'Unknown'}_${msg.id || msg.channel || '0'}`;
|
||||||
|
const primary = getPrimary(msg);
|
||||||
|
|
||||||
|
if (devices.has(key)) {
|
||||||
|
const dev = devices.get(key);
|
||||||
|
if (primary) {
|
||||||
|
dev.history.push(primary.value);
|
||||||
|
if (dev.history.length > MAX_SPARK_PTS) dev.history.shift();
|
||||||
|
dev.primaryColor = primary.color;
|
||||||
|
}
|
||||||
|
dev.card.innerHTML = buildCardHTML(msg, dev.history, dev.primaryColor);
|
||||||
|
const cls = getFlashClass(msg);
|
||||||
|
dev.card.classList.add(cls);
|
||||||
|
setTimeout(() => dev.card.classList.remove(cls), 820);
|
||||||
|
} else {
|
||||||
|
const history = primary ? [primary.value] : [];
|
||||||
|
const grid = document.getElementById('sensorDashboardGrid');
|
||||||
|
if (!grid) return;
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'sdb-card sdb-card--new';
|
||||||
|
card.innerHTML = buildCardHTML(msg, history, primary ? primary.color : '#4aa3ff');
|
||||||
|
grid.insertBefore(card, grid.firstChild);
|
||||||
|
setTimeout(() => card.classList.remove('sdb-card--new'), 2000);
|
||||||
|
devices.set(key, { card, history, primaryColor: primary ? primary.color : '#4aa3ff' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Show / hide / reset ----
|
||||||
|
|
||||||
|
function applyViewState(mode) {
|
||||||
|
const view = document.getElementById('sensorDashboardView');
|
||||||
|
const output = document.getElementById('output');
|
||||||
|
|
||||||
|
if (mode === 'sensor') {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY) || 'dashboard';
|
||||||
|
const isDash = saved === 'dashboard';
|
||||||
|
if (view) view.style.display = isDash ? 'block' : 'none';
|
||||||
|
if (output) output.style.display = isDash ? 'none' : '';
|
||||||
|
_updateToggle(isDash);
|
||||||
|
} else {
|
||||||
|
if (view) view.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
localStorage.setItem(STORAGE_KEY, 'dashboard');
|
||||||
|
applyViewState('sensor');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
localStorage.setItem(STORAGE_KEY, 'feed');
|
||||||
|
applyViewState('sensor');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateToggle(isDash) {
|
||||||
|
document.getElementById('sensorToggleDash')?.classList.toggle('view-toggle-btn--active', isDash);
|
||||||
|
document.getElementById('sensorToggleFeed')?.classList.toggle('view-toggle-btn--active', !isDash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
devices.clear();
|
||||||
|
const grid = document.getElementById('sensorDashboardGrid');
|
||||||
|
if (grid) grid.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { addReading, show, hide, reset, applyViewState };
|
||||||
|
})();
|
||||||
+73
-26
@@ -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);
|
|
||||||
graticuleLayer.addTo(map);
|
|
||||||
};
|
|
||||||
const removeGraticule = () => {
|
|
||||||
if (graticuleLayer) { map.removeLayer(graticuleLayer); graticuleLayer = null; }
|
|
||||||
};
|
|
||||||
if (options.graticule) {
|
|
||||||
buildGraticule();
|
|
||||||
map.on('zoomend', buildGraticule);
|
|
||||||
cleanupFns.push(() => {
|
|
||||||
map.off('zoomend', buildGraticule);
|
|
||||||
removeGraticule();
|
|
||||||
});
|
});
|
||||||
}
|
handles.showGraticule = grat.show;
|
||||||
handles.showGraticule = () => {
|
handles.hideGraticule = grat.hide;
|
||||||
buildGraticule();
|
cleanupFns.push(() => grat.control.remove());
|
||||||
map.on('zoomend', buildGraticule);
|
|
||||||
};
|
|
||||||
handles.hideGraticule = () => {
|
|
||||||
map.off('zoomend', buildGraticule);
|
|
||||||
removeGraticule();
|
|
||||||
};
|
|
||||||
|
|
||||||
handles.removeAll = () => cleanupFns.forEach(fn => fn());
|
handles.removeAll = () => cleanupFns.forEach(fn => fn());
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user