chore: commit all pending changes

This commit is contained in:
Smittix
2026-02-23 16:51:32 +00:00
parent 94b358f686
commit 7241dbed35
19 changed files with 946 additions and 308 deletions

View File

@@ -96,7 +96,7 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
return None return None
def log_message(msg: dict[str, Any]) -> None: def log_message(msg: dict[str, Any]) -> None:
"""Log a message to file if logging is enabled.""" """Log a message to file if logging is enabled."""
if not app_module.logging_enabled: if not app_module.logging_enabled:
return return
@@ -104,25 +104,39 @@ def log_message(msg: dict[str, Any]) -> None:
with open(app_module.log_file_path, 'a') as f: with open(app_module.log_file_path, 'a') as f:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n") f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
except Exception as e: except Exception as e:
logger.error(f"Failed to log message: {e}") logger.error(f"Failed to log message: {e}")
def audio_relay_thread( def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
rtl_stdout, """Compress recent PCM samples into a signed 8-bit waveform for SSE."""
multimon_stdin, if not samples:
output_queue: queue.Queue, return []
stop_event: threading.Event,
) -> None: window = samples[-window_size:] if len(samples) > window_size else samples
"""Relay audio from rtl_fm to multimon-ng while computing signal levels. waveform: list[int] = []
for sample in window:
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight # Convert int16 PCM to int8 range for lightweight transport.
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope packed = int(round(sample / 256))
event onto *output_queue*. waveform.append(max(-127, min(127, packed)))
""" return waveform
CHUNK = 4096 # bytes 2048 samples at 16-bit mono
INTERVAL = 0.1 # seconds between scope updates
last_scope = time.monotonic() def audio_relay_thread(
rtl_stdout,
multimon_stdin,
output_queue: queue.Queue,
stop_event: threading.Event,
) -> None:
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
event plus a compact waveform sample onto *output_queue*.
"""
CHUNK = 4096 # bytes 2048 samples at 16-bit mono
INTERVAL = 0.1 # seconds between scope updates
last_scope = time.monotonic()
try: try:
while not stop_event.is_set(): while not stop_event.is_set():
@@ -146,15 +160,16 @@ def audio_relay_thread(
if n_samples == 0: if n_samples == 0:
continue continue
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2]) samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
peak = max(abs(s) for s in samples) peak = max(abs(s) for s in samples)
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples)) rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
output_queue.put_nowait({ output_queue.put_nowait({
'type': 'scope', 'type': 'scope',
'rms': rms, 'rms': rms,
'peak': peak, 'peak': peak,
}) 'waveform': _encode_scope_waveform(samples),
except (struct.error, ValueError, queue.Full): })
pass except (struct.error, ValueError, queue.Full):
pass
except Exception as e: except Exception as e:
logger.debug(f"Audio relay error: {e}") logger.debug(f"Audio relay error: {e}")
finally: finally:

View File

@@ -1,14 +1,15 @@
"""RTL_433 sensor monitoring routes.""" """RTL_433 sensor monitoring routes."""
from __future__ import annotations from __future__ import annotations
import json import json
import queue import math
import subprocess import queue
import threading import subprocess
import time import threading
from datetime import datetime import time
from typing import Generator from datetime import datetime
from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
@@ -28,12 +29,42 @@ sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used # Track which device is being used
sensor_active_device: int | None = None sensor_active_device: int | None = None
# RSSI history per device (model_id -> list of (timestamp, rssi)) # RSSI history per device (model_id -> list of (timestamp, rssi))
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {} sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
_MAX_RSSI_HISTORY = 60 _MAX_RSSI_HISTORY = 60
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 256) -> list[int]:
"""Synthesize a compact waveform from rtl_433 level metrics."""
points = max(32, min(points, 512))
# rssi is usually negative; stronger signals are closer to 0 dBm.
rssi_norm = min(max(abs(rssi) / 40.0, 0.0), 1.0)
snr_norm = min(max((snr + 5.0) / 35.0, 0.0), 1.0)
noise_norm = min(max(abs(noise) / 40.0, 0.0), 1.0)
amplitude = max(0.06, min(1.0, (0.6 * rssi_norm + 0.4 * snr_norm) - (0.22 * noise_norm)))
cycles = 3.0 + (snr_norm * 8.0)
harmonic = 0.25 + (0.35 * snr_norm)
hiss = 0.08 + (0.18 * noise_norm)
phase = (time.monotonic() * (1.4 + (snr_norm * 2.2))) % (2.0 * math.pi)
waveform: list[int] = []
for i in range(points):
t = i / (points - 1)
base = math.sin((2.0 * math.pi * cycles * t) + phase)
overtone = math.sin((2.0 * math.pi * (cycles * 2.4) * t) + (phase * 0.7))
noise_wobble = math.sin((2.0 * math.pi * (cycles * 7.0) * t) + (phase * 2.1))
sample = amplitude * (base + (harmonic * overtone) + (hiss * noise_wobble))
sample /= (1.0 + harmonic + hiss)
packed = int(round(max(-1.0, min(1.0, sample)) * 127.0))
waveform.append(max(-127, min(127, packed)))
return waveform
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
"""Stream rtl_433 JSON output to queue.""" """Stream rtl_433 JSON output to queue."""
try: try:
app_module.sensor_queue.put({'type': 'status', 'text': 'started'}) app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
@@ -64,16 +95,24 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
rssi = data.get('rssi') rssi = data.get('rssi')
snr = data.get('snr') snr = data.get('snr')
noise = data.get('noise') noise = data.get('noise')
if rssi is not None or snr is not None: if rssi is not None or snr is not None:
try: try:
app_module.sensor_queue.put_nowait({ rssi_value = float(rssi) if rssi is not None else 0.0
'type': 'scope', snr_value = float(snr) if snr is not None else 0.0
'rssi': rssi if rssi is not None else 0, noise_value = float(noise) if noise is not None else 0.0
'snr': snr if snr is not None else 0, app_module.sensor_queue.put_nowait({
'noise': noise if noise is not None else 0, 'type': 'scope',
}) 'rssi': rssi_value,
except queue.Full: 'snr': snr_value,
pass 'noise': noise_value,
'waveform': _build_scope_waveform(
rssi=rssi_value,
snr=snr_value,
noise=noise_value,
),
})
except (TypeError, ValueError, queue.Full):
pass
# Log if enabled # Log if enabled
if app_module.logging_enabled: if app_module.logging_enabled:

View File

@@ -898,66 +898,76 @@ body {
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
overflow: hidden; overflow: hidden;
z-index: 650; z-index: 1200;
--target-x: 50%; --crosshair-x-start: 100%;
--target-y: 50%; --crosshair-y-start: 100%;
--crosshair-x-end: 50%;
--crosshair-y-end: 50%;
--crosshair-duration: 1500ms;
} }
.map-crosshair-line { .map-crosshair-line {
position: absolute; position: absolute;
opacity: 0; opacity: 0;
background: var(--accent-cyan); background: var(--accent-cyan);
box-shadow: 0 0 10px var(--accent-cyan); box-shadow: none;
will-change: transform, opacity;
} }
.map-crosshair-vertical { .map-crosshair-vertical {
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 2px; width: 1px;
right: 0; left: 0;
transform: translateX(0); transform: translateX(var(--crosshair-x-start));
} }
.map-crosshair-horizontal { .map-crosshair-horizontal {
left: 0; left: 0;
right: 0; right: 0;
height: 2px; height: 1px;
bottom: 0; top: 0;
transform: translateY(0); transform: translateY(var(--crosshair-y-start));
} }
.map-crosshair-overlay.active .map-crosshair-vertical { .map-crosshair-overlay.active .map-crosshair-vertical {
animation: mapCrosshairSweepX 620ms cubic-bezier(0.2, 0.85, 0.28, 1) forwards; animation: mapCrosshairSweepX var(--crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
} }
.map-crosshair-overlay.active .map-crosshair-horizontal { .map-crosshair-overlay.active .map-crosshair-horizontal {
animation: mapCrosshairSweepY 620ms cubic-bezier(0.2, 0.85, 0.28, 1) forwards; animation: mapCrosshairSweepY var(--crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
} }
@keyframes mapCrosshairSweepX { @keyframes mapCrosshairSweepX {
0% { 0% {
transform: translateX(0); transform: translateX(var(--crosshair-x-start));
opacity: 0.95; opacity: 0;
} }
75% { 12% {
opacity: 0.95; opacity: 1;
}
85% {
opacity: 1;
} }
100% { 100% {
transform: translateX(calc(var(--target-x) - 100%)); transform: translateX(var(--crosshair-x-end));
opacity: 0; opacity: 0;
} }
} }
@keyframes mapCrosshairSweepY { @keyframes mapCrosshairSweepY {
0% { 0% {
transform: translateY(0); transform: translateY(var(--crosshair-y-start));
opacity: 0.95; opacity: 0;
} }
75% { 12% {
opacity: 0.95; opacity: 1;
}
85% {
opacity: 1;
} }
100% { 100% {
transform: translateY(calc(var(--target-y) - 100%)); transform: translateY(var(--crosshair-y-end));
opacity: 0; opacity: 0;
} }
} }
@@ -965,7 +975,7 @@ body {
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.map-crosshair-overlay.active .map-crosshair-vertical, .map-crosshair-overlay.active .map-crosshair-vertical,
.map-crosshair-overlay.active .map-crosshair-horizontal { .map-crosshair-overlay.active .map-crosshair-horizontal {
animation-duration: 120ms; animation-duration: 220ms;
} }
} }

View File

@@ -479,10 +479,6 @@
filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05); filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05);
} }
.tile-layer-flir {
filter: grayscale(1) sepia(1) hue-rotate(-18deg) saturate(4.85) brightness(0.96) contrast(1.34);
}
/* Global Leaflet map theme: cyber overlay */ /* Global Leaflet map theme: cyber overlay */
.leaflet-container.map-theme-cyber { .leaflet-container.map-theme-cyber {
position: relative; position: relative;
@@ -531,55 +527,6 @@ html.map-cyber-enabled .leaflet-container::after {
background-size: 52px 52px, 52px 52px; background-size: 52px 52px, 52px 52px;
} }
/* Global Leaflet map theme: FLIR thermal overlay */
.leaflet-container.map-theme-flir {
position: relative;
background: #090602;
isolation: isolate;
}
.leaflet-container.map-theme-flir .leaflet-tile-pane {
filter: grayscale(1) sepia(1) hue-rotate(-18deg) saturate(4.85) brightness(0.96) contrast(1.34);
opacity: 1;
}
/* Hard global fallback: enforce FLIR tint on all Leaflet tile images */
html.map-flir-enabled .leaflet-container .leaflet-tile {
filter: grayscale(1) sepia(1) hue-rotate(-18deg) saturate(4.85) brightness(0.96) contrast(1.34) !important;
}
/* Hard global fallback: thermal glow + scanline/grid overlay */
html.map-flir-enabled .leaflet-container {
position: relative;
isolation: isolate;
}
html.map-flir-enabled .leaflet-container::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 620;
background:
radial-gradient(115% 90% at 50% 40%, rgba(255, 132, 28, 0.22), rgba(255, 132, 28, 0) 63%),
linear-gradient(180deg, rgba(14, 229, 255, 0.08) 0%, rgba(255, 96, 18, 0.15) 58%, rgba(255, 233, 128, 0.11) 100%);
}
html.map-flir-enabled .leaflet-container::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 621;
opacity: 0.32;
mix-blend-mode: screen;
background-image:
repeating-linear-gradient(0deg, rgba(255, 188, 92, 0.08) 0 1px, transparent 1px 3px),
linear-gradient(90deg, rgba(255, 141, 66, 0.12) 1px, transparent 1px),
linear-gradient(rgba(0, 240, 255, 0.07) 1px, transparent 1px);
background-size: 100% 3px, 68px 68px, 68px 68px;
}
/* Responsive */ /* Responsive */
@media (max-width: 960px) { @media (max-width: 960px) {
.settings-tabs { .settings-tabs {

View File

@@ -33,15 +33,6 @@ const Settings = {
mapTheme: 'cyber', mapTheme: 'cyber',
options: {} options: {}
}, },
cartodb_dark_flir: {
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd',
mapTheme: 'flir',
options: {
className: 'tile-layer-flir'
}
},
cartodb_light: { cartodb_light: {
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png', url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
@@ -116,7 +107,6 @@ const Settings = {
const resolvedConfig = config || this.getTileConfig(); const resolvedConfig = config || this.getTileConfig();
const themeClass = this._getMapThemeClass(resolvedConfig); const themeClass = this._getMapThemeClass(resolvedConfig);
document.documentElement.classList.toggle('map-cyber-enabled', themeClass === 'map-theme-cyber'); document.documentElement.classList.toggle('map-cyber-enabled', themeClass === 'map-theme-cyber');
document.documentElement.classList.toggle('map-flir-enabled', themeClass === 'map-theme-flir');
}, },
/** /**
@@ -351,7 +341,6 @@ const Settings = {
_getMapThemeClass(config) { _getMapThemeClass(config) {
if (!config || !config.mapTheme) return null; if (!config || !config.mapTheme) return null;
if (config.mapTheme === 'cyber') return 'map-theme-cyber'; if (config.mapTheme === 'cyber') return 'map-theme-cyber';
if (config.mapTheme === 'flir') return 'map-theme-flir';
return null; return null;
}, },
@@ -375,7 +364,7 @@ const Settings = {
container.style.background = ''; container.style.background = '';
} }
container.classList.remove('map-theme-cyber', 'map-theme-flir'); container.classList.remove('map-theme-cyber');
const resolvedConfig = config || this.getTileConfig(); const resolvedConfig = config || this.getTileConfig();
const themeClass = this._getMapThemeClass(resolvedConfig); const themeClass = this._getMapThemeClass(resolvedConfig);
@@ -392,15 +381,6 @@ const Settings = {
tilePane.style.opacity = '1'; tilePane.style.opacity = '1';
tilePane.style.willChange = 'filter'; tilePane.style.willChange = 'filter';
} }
} else if (themeClass === 'map-theme-flir') {
if (container.style) {
container.style.background = '#090602';
}
if (tilePane && tilePane.style) {
tilePane.style.filter = 'grayscale(1) sepia(1) hue-rotate(-18deg) saturate(4.85) brightness(0.96) contrast(1.34)';
tilePane.style.opacity = '1';
tilePane.style.willChange = 'filter';
}
} }
// Map overlays are rendered via CSS pseudo elements on // Map overlays are rendered via CSS pseudo elements on

View File

@@ -15,13 +15,21 @@ const SSTVGeneral = (function() {
let sstvGeneralScopeCtx = null; let sstvGeneralScopeCtx = null;
let sstvGeneralScopeAnim = null; let sstvGeneralScopeAnim = null;
let sstvGeneralScopeHistory = []; let sstvGeneralScopeHistory = [];
let sstvGeneralScopeWaveBuffer = [];
let sstvGeneralScopeDisplayWave = [];
const SSTV_GENERAL_SCOPE_LEN = 200; const SSTV_GENERAL_SCOPE_LEN = 200;
const SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN = 2048;
const SSTV_GENERAL_SCOPE_WAVE_INPUT_SMOOTH_ALPHA = 0.55;
const SSTV_GENERAL_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA = 0.22;
const SSTV_GENERAL_SCOPE_WAVE_IDLE_DECAY = 0.96;
let sstvGeneralScopeRms = 0; let sstvGeneralScopeRms = 0;
let sstvGeneralScopePeak = 0; let sstvGeneralScopePeak = 0;
let sstvGeneralScopeTargetRms = 0; let sstvGeneralScopeTargetRms = 0;
let sstvGeneralScopeTargetPeak = 0; let sstvGeneralScopeTargetPeak = 0;
let sstvGeneralScopeMsgBurst = 0; let sstvGeneralScopeMsgBurst = 0;
let sstvGeneralScopeTone = null; let sstvGeneralScopeTone = null;
let sstvGeneralScopeLastWaveAt = 0;
let sstvGeneralScopeLastInputSample = 0;
/** /**
* Initialize the SSTV General mode * Initialize the SSTV General mode
@@ -205,20 +213,64 @@ const SSTVGeneral = (function() {
/** /**
* Initialize signal scope canvas * Initialize signal scope canvas
*/ */
function resizeSstvGeneralScopeCanvas(canvas) {
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const width = Math.max(1, Math.floor(rect.width * dpr));
const height = Math.max(1, Math.floor(rect.height * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
}
function applySstvGeneralScopeData(scopeData) {
if (!scopeData || typeof scopeData !== 'object') return;
sstvGeneralScopeTargetRms = Number(scopeData.rms) || 0;
sstvGeneralScopeTargetPeak = Number(scopeData.peak) || 0;
if (scopeData.tone !== undefined) {
sstvGeneralScopeTone = scopeData.tone;
}
if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) {
for (const packedSample of scopeData.waveform) {
const sample = Number(packedSample);
if (!Number.isFinite(sample)) continue;
const normalized = Math.max(-127, Math.min(127, sample)) / 127;
sstvGeneralScopeLastInputSample += (normalized - sstvGeneralScopeLastInputSample) * SSTV_GENERAL_SCOPE_WAVE_INPUT_SMOOTH_ALPHA;
sstvGeneralScopeWaveBuffer.push(sstvGeneralScopeLastInputSample);
}
if (sstvGeneralScopeWaveBuffer.length > SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN) {
sstvGeneralScopeWaveBuffer.splice(0, sstvGeneralScopeWaveBuffer.length - SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN);
}
sstvGeneralScopeLastWaveAt = performance.now();
}
}
function initSstvGeneralScope() { function initSstvGeneralScope() {
const canvas = document.getElementById('sstvGeneralScopeCanvas'); const canvas = document.getElementById('sstvGeneralScopeCanvas');
if (!canvas) return; if (!canvas) return;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * (window.devicePixelRatio || 1); if (sstvGeneralScopeAnim) {
canvas.height = rect.height * (window.devicePixelRatio || 1); cancelAnimationFrame(sstvGeneralScopeAnim);
sstvGeneralScopeAnim = null;
}
resizeSstvGeneralScopeCanvas(canvas);
sstvGeneralScopeCtx = canvas.getContext('2d'); sstvGeneralScopeCtx = canvas.getContext('2d');
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0); sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
sstvGeneralScopeWaveBuffer = [];
sstvGeneralScopeDisplayWave = [];
sstvGeneralScopeRms = 0; sstvGeneralScopeRms = 0;
sstvGeneralScopePeak = 0; sstvGeneralScopePeak = 0;
sstvGeneralScopeTargetRms = 0; sstvGeneralScopeTargetRms = 0;
sstvGeneralScopeTargetPeak = 0; sstvGeneralScopeTargetPeak = 0;
sstvGeneralScopeMsgBurst = 0; sstvGeneralScopeMsgBurst = 0;
sstvGeneralScopeTone = null; sstvGeneralScopeTone = null;
sstvGeneralScopeLastWaveAt = 0;
sstvGeneralScopeLastInputSample = 0;
drawSstvGeneralScope(); drawSstvGeneralScope();
} }
@@ -228,12 +280,14 @@ const SSTVGeneral = (function() {
function drawSstvGeneralScope() { function drawSstvGeneralScope() {
const ctx = sstvGeneralScopeCtx; const ctx = sstvGeneralScopeCtx;
if (!ctx) return; if (!ctx) return;
resizeSstvGeneralScopeCanvas(ctx.canvas);
const W = ctx.canvas.width; const W = ctx.canvas.width;
const H = ctx.canvas.height; const H = ctx.canvas.height;
const midY = H / 2; const midY = H / 2;
// Phosphor persistence // Phosphor persistence
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)'; ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
ctx.fillRect(0, 0, W, H); ctx.fillRect(0, 0, W, H);
// Smooth towards target // Smooth towards target
@@ -256,32 +310,84 @@ const SSTVGeneral = (function() {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
} }
// Waveform // Envelope
const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1); const envStepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
ctx.strokeStyle = '#c080ff'; ctx.strokeStyle = 'rgba(168, 110, 255, 0.45)';
ctx.lineWidth = 1.5; ctx.lineWidth = 1;
ctx.shadowColor = '#c080ff';
ctx.shadowBlur = 4;
// Upper half
ctx.beginPath(); ctx.beginPath();
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) { for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
const x = i * stepX; const x = i * envStepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.9; const amp = sstvGeneralScopeHistory[i] * midY * 0.85;
const y = midY - amp; const y = midY - amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
} }
ctx.stroke(); ctx.stroke();
// Lower half (mirror)
ctx.beginPath(); ctx.beginPath();
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) { for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
const x = i * stepX; const x = i * envStepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.9; const amp = sstvGeneralScopeHistory[i] * midY * 0.85;
const y = midY + amp; const y = midY + amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
} }
ctx.stroke(); ctx.stroke();
// Actual waveform trace
const waveformPointCount = Math.min(Math.max(120, Math.floor(W / 3.2)), 420);
if (sstvGeneralScopeWaveBuffer.length > 1) {
const waveIsFresh = (performance.now() - sstvGeneralScopeLastWaveAt) < 1000;
const sourceLen = sstvGeneralScopeWaveBuffer.length;
const sourceWindow = Math.min(sourceLen, 1536);
const sourceStart = sourceLen - sourceWindow;
if (sstvGeneralScopeDisplayWave.length !== waveformPointCount) {
sstvGeneralScopeDisplayWave = new Array(waveformPointCount).fill(0);
}
for (let i = 0; i < waveformPointCount; i++) {
const a = sourceStart + Math.floor((i / waveformPointCount) * sourceWindow);
const b = sourceStart + Math.floor(((i + 1) / waveformPointCount) * sourceWindow);
const start = Math.max(sourceStart, Math.min(sourceLen - 1, a));
const end = Math.max(start + 1, Math.min(sourceLen, b));
let sum = 0;
let count = 0;
for (let j = start; j < end; j++) {
sum += sstvGeneralScopeWaveBuffer[j];
count++;
}
const targetSample = count > 0 ? (sum / count) : 0;
sstvGeneralScopeDisplayWave[i] += (targetSample - sstvGeneralScopeDisplayWave[i]) * SSTV_GENERAL_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA;
}
ctx.strokeStyle = waveIsFresh ? '#c080ff' : 'rgba(192, 128, 255, 0.45)';
ctx.lineWidth = 1.7;
ctx.shadowColor = '#c080ff';
ctx.shadowBlur = waveIsFresh ? 6 : 2;
const stepX = waveformPointCount > 1 ? (W / (waveformPointCount - 1)) : W;
ctx.beginPath();
const firstY = midY - (sstvGeneralScopeDisplayWave[0] * midY * 0.9);
ctx.moveTo(0, firstY);
for (let i = 1; i < waveformPointCount - 1; i++) {
const x = i * stepX;
const y = midY - (sstvGeneralScopeDisplayWave[i] * midY * 0.9);
const nx = (i + 1) * stepX;
const ny = midY - (sstvGeneralScopeDisplayWave[i + 1] * midY * 0.9);
const cx = (x + nx) / 2;
const cy = (y + ny) / 2;
ctx.quadraticCurveTo(x, y, cx, cy);
}
const lastX = (waveformPointCount - 1) * stepX;
const lastY = midY - (sstvGeneralScopeDisplayWave[waveformPointCount - 1] * midY * 0.9);
ctx.lineTo(lastX, lastY);
ctx.stroke();
if (!waveIsFresh) {
for (let i = 0; i < sstvGeneralScopeDisplayWave.length; i++) {
sstvGeneralScopeDisplayWave[i] *= SSTV_GENERAL_SCOPE_WAVE_IDLE_DECAY;
}
}
}
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
// Peak indicator // Peak indicator
@@ -317,8 +423,17 @@ const SSTVGeneral = (function() {
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; } else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
} }
if (statusLabel) { if (statusLabel) {
if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; } const waveIsFresh = (performance.now() - sstvGeneralScopeLastWaveAt) < 1000;
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; } if (sstvGeneralScopeRms > 900 && waveIsFresh) {
statusLabel.textContent = 'DEMODULATING';
statusLabel.style.color = '#c080ff';
} else if (sstvGeneralScopeRms > 500) {
statusLabel.textContent = 'CARRIER';
statusLabel.style.color = '#e0b8ff';
} else {
statusLabel.textContent = 'QUIET';
statusLabel.style.color = '#555';
}
} }
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope); sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
@@ -330,6 +445,11 @@ const SSTVGeneral = (function() {
function stopSstvGeneralScope() { function stopSstvGeneralScope() {
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; } if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
sstvGeneralScopeCtx = null; sstvGeneralScopeCtx = null;
sstvGeneralScopeWaveBuffer = [];
sstvGeneralScopeDisplayWave = [];
sstvGeneralScopeHistory = [];
sstvGeneralScopeLastWaveAt = 0;
sstvGeneralScopeLastInputSample = 0;
} }
/** /**
@@ -353,9 +473,7 @@ const SSTVGeneral = (function() {
if (data.type === 'sstv_progress') { if (data.type === 'sstv_progress') {
handleProgress(data); handleProgress(data);
} else if (data.type === 'sstv_scope') { } else if (data.type === 'sstv_scope') {
sstvGeneralScopeTargetRms = data.rms; applySstvGeneralScopeData(data);
sstvGeneralScopeTargetPeak = data.peak;
if (data.tone !== undefined) sstvGeneralScopeTone = data.tone;
} }
} catch (err) { } catch (err) {
console.error('Failed to parse SSE message:', err); console.error('Failed to parse SSE message:', err);

View File

@@ -17,21 +17,51 @@ const STATIC_PREFIXES = [
'/static/fonts/', '/static/fonts/',
]; ];
const CACHE_EXACT = ['/manifest.json']; const CACHE_EXACT = ['/manifest.json'];
function isNetworkOnly(req) { function isHttpRequest(req) {
if (req.method !== 'GET') return true; const url = new URL(req.url);
const accept = req.headers.get('Accept') || ''; return url.protocol === 'http:' || url.protocol === 'https:';
if (accept.includes('text/event-stream')) return true; }
const url = new URL(req.url);
function isNetworkOnly(req) {
if (req.method !== 'GET') return true;
const accept = req.headers.get('Accept') || '';
if (accept.includes('text/event-stream')) return true;
const url = new URL(req.url);
return NETWORK_ONLY_PREFIXES.some(p => url.pathname.startsWith(p)); return NETWORK_ONLY_PREFIXES.some(p => url.pathname.startsWith(p));
} }
function isStaticAsset(req) { function isStaticAsset(req) {
const url = new URL(req.url); const url = new URL(req.url);
if (CACHE_EXACT.includes(url.pathname)) return true; if (CACHE_EXACT.includes(url.pathname)) return true;
return STATIC_PREFIXES.some(p => url.pathname.startsWith(p)); return STATIC_PREFIXES.some(p => url.pathname.startsWith(p));
} }
function fallbackResponse(req, status = 503) {
const accept = req.headers.get('Accept') || '';
if (accept.includes('application/json')) {
return new Response(
JSON.stringify({ status: 'error', message: 'Network unavailable' }),
{
status,
headers: { 'Content-Type': 'application/json' },
}
);
}
if (accept.includes('text/event-stream')) {
return new Response('', {
status,
headers: { 'Content-Type': 'text/event-stream' },
});
}
return new Response('Offline', {
status,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
self.addEventListener('install', (e) => { self.addEventListener('install', (e) => {
self.skipWaiting(); self.skipWaiting();
@@ -45,14 +75,21 @@ self.addEventListener('activate', (e) => {
); );
}); });
self.addEventListener('fetch', (e) => { self.addEventListener('fetch', (e) => {
const req = e.request; const req = e.request;
// Always bypass service worker for non-GET and streaming routes // Ignore non-HTTP(S) requests so extensions/browser-internal URLs are untouched.
if (isNetworkOnly(req)) { if (!isHttpRequest(req)) {
e.respondWith(fetch(req)); return;
return; }
}
// Always bypass service worker for non-GET and streaming routes
if (isNetworkOnly(req)) {
e.respondWith(
fetch(req).catch(() => fallbackResponse(req, 503))
);
return;
}
// Cache-first for static assets // Cache-first for static assets
if (isStaticAsset(req)) { if (isStaticAsset(req)) {
@@ -65,15 +102,15 @@ self.addEventListener('fetch', (e) => {
if (res && res.status === 200) cache.put(req, res.clone()); if (res && res.status === 200) cache.put(req, res.clone());
}).catch(() => {}); }).catch(() => {});
return cached; return cached;
} }
return fetch(req).then(res => { return fetch(req).then(res => {
if (res && res.status === 200) cache.put(req, res.clone()); if (res && res.status === 200) cache.put(req, res.clone());
return res; return res;
}); }).catch(() => fallbackResponse(req, 504));
}) })
) )
); );
return; return;
} }
// Network-first for HTML pages // Network-first for HTML pages

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% elif offline_settings.tile_provider == 'cartodb_dark_flir' %}map-flir-enabled{% endif %}"> <html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -423,9 +423,16 @@
let alertsEnabled = true; let alertsEnabled = true;
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
let soundedAircraft = {}; // Track aircraft we've played detection sound for let soundedAircraft = {}; // Track aircraft we've played detection sound for
const MAP_CROSSHAIR_DURATION_MS = 620; const MAP_CROSSHAIR_DURATION_MS = 1500;
const PANEL_SELECTION_BASE_ZOOM = 10;
const PANEL_SELECTION_MAX_ZOOM = 12;
const PANEL_SELECTION_ZOOM_INCREMENT = 1.4;
const PANEL_SELECTION_STAGE1_DURATION_SEC = 1.05;
const PANEL_SELECTION_STAGE2_DURATION_SEC = 1.15;
const PANEL_SELECTION_STAGE_GAP_MS = 180;
let mapCrosshairResetTimer = null; let mapCrosshairResetTimer = null;
let mapCrosshairFallbackTimer = null; let panelSelectionFallbackTimer = null;
let panelSelectionStageTimer = null;
let mapCrosshairRequestId = 0; let mapCrosshairRequestId = 0;
// Watchlist - persisted to localStorage // Watchlist - persisted to localStorage
let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]'); let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]');
@@ -2792,18 +2799,32 @@ sudo make install</code>
`; `;
} }
function triggerMapCrosshairAnimation(lat, lon) { function triggerMapCrosshairAnimation(lat, lon, durationMs = MAP_CROSSHAIR_DURATION_MS, lockToMapCenter = false) {
if (!radarMap) return; if (!radarMap) return;
const overlay = document.getElementById('mapCrosshairOverlay'); const overlay = document.getElementById('mapCrosshairOverlay');
if (!overlay) return; if (!overlay) return;
const point = radarMap.latLngToContainerPoint([lat, lon]);
const size = radarMap.getSize(); const size = radarMap.getSize();
const targetX = Math.max(0, Math.min(size.x, point.x)); let targetX;
const targetY = Math.max(0, Math.min(size.y, point.y)); let targetY;
overlay.style.setProperty('--target-x', `${targetX}px`); if (lockToMapCenter) {
overlay.style.setProperty('--target-y', `${targetY}px`); targetX = size.x / 2;
targetY = size.y / 2;
} else {
const point = radarMap.latLngToContainerPoint([lat, lon]);
targetX = Math.max(0, Math.min(size.x, point.x));
targetY = Math.max(0, Math.min(size.y, point.y));
}
const startX = size.x + 8;
const startY = size.y + 8;
overlay.style.setProperty('--crosshair-x-start', `${startX}px`);
overlay.style.setProperty('--crosshair-y-start', `${startY}px`);
overlay.style.setProperty('--crosshair-x-end', `${targetX}px`);
overlay.style.setProperty('--crosshair-y-end', `${targetY}px`);
overlay.style.setProperty('--crosshair-duration', `${durationMs}ms`);
overlay.classList.remove('active'); overlay.classList.remove('active');
void overlay.offsetWidth; void overlay.offsetWidth;
overlay.classList.add('active'); overlay.classList.add('active');
@@ -2814,16 +2835,102 @@ sudo make install</code>
mapCrosshairResetTimer = setTimeout(() => { mapCrosshairResetTimer = setTimeout(() => {
overlay.classList.remove('active'); overlay.classList.remove('active');
mapCrosshairResetTimer = null; mapCrosshairResetTimer = null;
}, MAP_CROSSHAIR_DURATION_MS + 40); }, durationMs + 100);
}
function getPanelSelectionFinalZoom() {
if (!radarMap) return PANEL_SELECTION_BASE_ZOOM;
const currentZoom = radarMap.getZoom();
const maxZoom = typeof radarMap.getMaxZoom === 'function' ? radarMap.getMaxZoom() : PANEL_SELECTION_MAX_ZOOM;
return Math.min(
PANEL_SELECTION_MAX_ZOOM,
maxZoom,
Math.max(PANEL_SELECTION_BASE_ZOOM, currentZoom + PANEL_SELECTION_ZOOM_INCREMENT)
);
}
function getPanelSelectionIntermediateZoom(finalZoom) {
if (!radarMap) return finalZoom;
const currentZoom = radarMap.getZoom();
if (finalZoom - currentZoom < 0.8) {
return finalZoom;
}
const midpointZoom = currentZoom + ((finalZoom - currentZoom) * 0.55);
return Math.min(finalZoom - 0.45, midpointZoom);
}
function runPanelSelectionAnimation(lat, lon, requestId) {
if (!radarMap) return;
const finalZoom = getPanelSelectionFinalZoom();
const intermediateZoom = getPanelSelectionIntermediateZoom(finalZoom);
const sequenceDurationMs = Math.round(
((PANEL_SELECTION_STAGE1_DURATION_SEC + PANEL_SELECTION_STAGE2_DURATION_SEC) * 1000) +
PANEL_SELECTION_STAGE_GAP_MS + 260
);
const startSecondStage = () => {
if (requestId !== mapCrosshairRequestId) return;
radarMap.flyTo([lat, lon], finalZoom, {
animate: true,
duration: PANEL_SELECTION_STAGE2_DURATION_SEC,
easeLinearity: 0.2
});
};
triggerMapCrosshairAnimation(
lat,
lon,
Math.max(MAP_CROSSHAIR_DURATION_MS, sequenceDurationMs),
true
);
if (intermediateZoom >= finalZoom - 0.1) {
radarMap.flyTo([lat, lon], finalZoom, {
animate: true,
duration: PANEL_SELECTION_STAGE2_DURATION_SEC,
easeLinearity: 0.2
});
return;
}
let stage1Handled = false;
const finishStage1 = () => {
if (stage1Handled || requestId !== mapCrosshairRequestId) return;
stage1Handled = true;
if (panelSelectionFallbackTimer) {
clearTimeout(panelSelectionFallbackTimer);
panelSelectionFallbackTimer = null;
}
panelSelectionStageTimer = setTimeout(() => {
panelSelectionStageTimer = null;
startSecondStage();
}, PANEL_SELECTION_STAGE_GAP_MS);
};
radarMap.once('moveend', finishStage1);
panelSelectionFallbackTimer = setTimeout(
finishStage1,
Math.round(PANEL_SELECTION_STAGE1_DURATION_SEC * 1000) + 160
);
radarMap.flyTo([lat, lon], intermediateZoom, {
animate: true,
duration: PANEL_SELECTION_STAGE1_DURATION_SEC,
easeLinearity: 0.2
});
} }
function selectAircraft(icao, source = 'map') { function selectAircraft(icao, source = 'map') {
const prevSelected = selectedIcao; const prevSelected = selectedIcao;
selectedIcao = icao; selectedIcao = icao;
mapCrosshairRequestId += 1; mapCrosshairRequestId += 1;
if (mapCrosshairFallbackTimer) { if (panelSelectionFallbackTimer) {
clearTimeout(mapCrosshairFallbackTimer); clearTimeout(panelSelectionFallbackTimer);
mapCrosshairFallbackTimer = null; panelSelectionFallbackTimer = null;
}
if (panelSelectionStageTimer) {
clearTimeout(panelSelectionStageTimer);
panelSelectionStageTimer = null;
} }
// Update marker icons for both previous and new selection // Update marker icons for both previous and new selection
@@ -2853,19 +2960,8 @@ sudo make install</code>
const targetLon = ac.lon; const targetLon = ac.lon;
if (source === 'panel' && radarMap) { if (source === 'panel' && radarMap) {
const requestId = mapCrosshairRequestId; runPanelSelectionAnimation(targetLat, targetLon, mapCrosshairRequestId);
let crosshairTriggered = false; return;
const runCrosshair = () => {
if (crosshairTriggered || requestId !== mapCrosshairRequestId) return;
crosshairTriggered = true;
if (mapCrosshairFallbackTimer) {
clearTimeout(mapCrosshairFallbackTimer);
mapCrosshairFallbackTimer = null;
}
triggerMapCrosshairAnimation(targetLat, targetLon);
};
radarMap.once('moveend', runCrosshair);
mapCrosshairFallbackTimer = setTimeout(runCrosshair, 450);
} }
radarMap.setView([targetLat, targetLon], 10); radarMap.setView([targetLat, targetLon], 10);

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% elif offline_settings.tile_provider == 'cartodb_dark_flir' %}map-flir-enabled{% endif %}"> <html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% elif offline_settings.tile_provider == 'cartodb_dark_flir' %}map-flir-enabled{% endif %}"> <html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% elif offline_settings.tile_provider == 'cartodb_dark_flir' %}map-flir-enabled{% endif %}"> <html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% elif offline_settings.tile_provider == 'cartodb_dark_flir' %}map-flir-enabled{% endif %}"> <html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -2193,7 +2193,7 @@
<div id="sstvScopePanel" style="display: none; margin-bottom: 12px;"> <div id="sstvScopePanel" style="display: none; margin-bottom: 12px;">
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;"> <div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
<span>Signal Scope</span> <span>Audio Waveform</span>
<div style="display: flex; gap: 14px;"> <div style="display: flex; gap: 14px;">
<span>RMS: <span id="sstvScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span> <span>RMS: <span id="sstvScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span>
<span>PEAK: <span id="sstvScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span> <span>PEAK: <span id="sstvScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
@@ -2461,7 +2461,7 @@
<div id="sstvGeneralScopePanel" style="display: none; margin-bottom: 12px;"> <div id="sstvGeneralScopePanel" style="display: none; margin-bottom: 12px;">
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;"> <div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
<span>Signal Scope</span> <span>Audio Waveform</span>
<div style="display: flex; gap: 14px;"> <div style="display: flex; gap: 14px;">
<span>RMS: <span id="sstvGeneralScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span> <span>RMS: <span id="sstvGeneralScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span>
<span>PEAK: <span id="sstvGeneralScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span> <span>PEAK: <span id="sstvGeneralScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
@@ -2835,7 +2835,7 @@
<div id="pagerScopePanel" style="display: none; margin-bottom: 12px;"> <div id="pagerScopePanel" style="display: none; margin-bottom: 12px;">
<div style="background: #0a0a0a; border: 1px solid #1a1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;"> <div style="background: #0a0a0a; border: 1px solid #1a1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
<span>Signal Scope</span> <span>Audio Waveform</span>
<div style="display: flex; gap: 14px;"> <div style="display: flex; gap: 14px;">
<span>RMS: <span id="scopeRmsLabel" style="color: #0ff; font-variant-numeric: tabular-nums;">0</span></span> <span>RMS: <span id="scopeRmsLabel" style="color: #0ff; font-variant-numeric: tabular-nums;">0</span></span>
<span>PEAK: <span id="scopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span> <span>PEAK: <span id="scopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
@@ -2853,7 +2853,7 @@
<div id="sensorScopePanel" style="display: none; margin-bottom: 12px;"> <div id="sensorScopePanel" style="display: none; margin-bottom: 12px;">
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;"> <div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
<span>Signal Scope</span> <span>Audio Waveform</span>
<div style="display: flex; gap: 14px;"> <div style="display: flex; gap: 14px;">
<span>RSSI: <span id="sensorScopeRssiLabel" style="color: #0f0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span> <span>RSSI: <span id="sensorScopeRssiLabel" style="color: #0f0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span>
<span>SNR: <span id="sensorScopeSnrLabel" style="color: #fa0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span> <span>SNR: <span id="sensorScopeSnrLabel" style="color: #fa0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span>
@@ -3934,61 +3934,157 @@
let sensorScopeCtx = null; let sensorScopeCtx = null;
let sensorScopeAnim = null; let sensorScopeAnim = null;
let sensorScopeHistory = []; let sensorScopeHistory = [];
let sensorScopeWaveBuffer = [];
let sensorScopeDisplayWave = [];
const SENSOR_SCOPE_LEN = 200; const SENSOR_SCOPE_LEN = 200;
const SENSOR_SCOPE_WAVE_BUFFER_LEN = 2048;
const SENSOR_SCOPE_WAVE_INPUT_SMOOTH_ALPHA = 0.55;
const SENSOR_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA = 0.22;
const SENSOR_SCOPE_WAVE_IDLE_DECAY = 0.96;
let sensorScopeRssi = 0; let sensorScopeRssi = 0;
let sensorScopeSnr = 0; let sensorScopeSnr = 0;
let sensorScopeTargetRssi = 0; let sensorScopeTargetRssi = 0;
let sensorScopeTargetSnr = 0; let sensorScopeTargetSnr = 0;
let sensorScopeMsgBurst = 0; let sensorScopeMsgBurst = 0;
let sensorScopeLastPulse = 0; let sensorScopeLastWaveAt = 0;
let sensorScopeLastInputSample = 0;
function resizeSensorScopeCanvas(canvas) {
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const width = Math.max(1, Math.floor(rect.width * dpr));
const height = Math.max(1, Math.floor(rect.height * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
}
function buildSensorWaveformFallback(rssi, snr, noise, points = 160) {
const rssiNorm = Math.min(Math.max(Math.abs(rssi) / 40, 0), 1);
const snrNorm = Math.min(Math.max((snr + 5) / 35, 0), 1);
const noiseNorm = Math.min(Math.max(Math.abs(noise) / 40, 0), 1);
const amplitude = Math.max(0.06, Math.min(1.0, (0.6 * rssiNorm + 0.4 * snrNorm) - (0.22 * noiseNorm)));
const cycles = 3 + (snrNorm * 8);
const harmonic = 0.25 + (0.35 * snrNorm);
const hiss = 0.08 + (0.18 * noiseNorm);
const phase = (performance.now() * 0.002 * (1.4 + (snrNorm * 2.2))) % (Math.PI * 2);
const waveform = [];
for (let i = 0; i < points; i++) {
const t = points > 1 ? (i / (points - 1)) : 0;
const base = Math.sin((Math.PI * 2 * cycles * t) + phase);
const overtone = Math.sin((Math.PI * 2 * (cycles * 2.4) * t) + (phase * 0.7));
const noiseWobble = Math.sin((Math.PI * 2 * (cycles * 7.0) * t) + (phase * 2.1));
let sample = amplitude * (base + (harmonic * overtone) + (hiss * noiseWobble));
sample /= (1 + harmonic + hiss);
waveform.push(Math.round(Math.max(-1, Math.min(1, sample)) * 127));
}
return waveform;
}
function appendSensorWaveformSamples(waveform) {
if (!Array.isArray(waveform) || waveform.length === 0) return;
for (const packedSample of waveform) {
const sample = Number(packedSample);
if (!Number.isFinite(sample)) continue;
const normalized = Math.max(-127, Math.min(127, sample)) / 127;
sensorScopeLastInputSample += (normalized - sensorScopeLastInputSample) * SENSOR_SCOPE_WAVE_INPUT_SMOOTH_ALPHA;
sensorScopeWaveBuffer.push(sensorScopeLastInputSample);
}
if (sensorScopeWaveBuffer.length > SENSOR_SCOPE_WAVE_BUFFER_LEN) {
sensorScopeWaveBuffer.splice(0, sensorScopeWaveBuffer.length - SENSOR_SCOPE_WAVE_BUFFER_LEN);
}
sensorScopeLastWaveAt = performance.now();
}
function applySensorScopeData(scopeData) {
if (!scopeData || typeof scopeData !== 'object') return;
const parsedRssi = Number(scopeData.rssi);
const parsedSnr = Number(scopeData.snr);
const parsedNoise = Number(scopeData.noise);
const rssi = Number.isFinite(parsedRssi) ? parsedRssi : 0;
const snr = Number.isFinite(parsedSnr) ? parsedSnr : 0;
const noise = Number.isFinite(parsedNoise) ? parsedNoise : 0;
sensorScopeTargetRssi = rssi;
sensorScopeTargetSnr = snr;
if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) {
appendSensorWaveformSamples(scopeData.waveform);
} else {
appendSensorWaveformSamples(buildSensorWaveformFallback(rssi, snr, noise));
}
}
function initSensorScope() { function initSensorScope() {
const canvas = document.getElementById('sensorScopeCanvas'); const canvas = document.getElementById('sensorScopeCanvas');
if (!canvas) return; if (!canvas) return;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * (window.devicePixelRatio || 1); if (sensorScopeAnim) {
canvas.height = rect.height * (window.devicePixelRatio || 1); cancelAnimationFrame(sensorScopeAnim);
sensorScopeAnim = null;
}
resizeSensorScopeCanvas(canvas);
sensorScopeCtx = canvas.getContext('2d'); sensorScopeCtx = canvas.getContext('2d');
sensorScopeHistory = new Array(SENSOR_SCOPE_LEN).fill(0); sensorScopeHistory = new Array(SENSOR_SCOPE_LEN).fill(0);
sensorScopeWaveBuffer = [];
sensorScopeDisplayWave = [];
sensorScopeRssi = 0; sensorScopeRssi = 0;
sensorScopeSnr = 0; sensorScopeSnr = 0;
sensorScopeTargetRssi = 0; sensorScopeTargetRssi = 0;
sensorScopeTargetSnr = 0; sensorScopeTargetSnr = 0;
sensorScopeMsgBurst = 0; sensorScopeMsgBurst = 0;
sensorScopeLastPulse = 0; sensorScopeLastWaveAt = 0;
sensorScopeLastInputSample = 0;
drawSensorScope(); drawSensorScope();
} }
function drawSensorScope() { function drawSensorScope() {
const ctx = sensorScopeCtx; const ctx = sensorScopeCtx;
if (!ctx) return; if (!ctx) return;
resizeSensorScopeCanvas(ctx.canvas);
const W = ctx.canvas.width; const W = ctx.canvas.width;
const H = ctx.canvas.height; const H = ctx.canvas.height;
const midY = H / 2; const midY = H / 2;
// Phosphor persistence // Phosphor persistence
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)'; ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
ctx.fillRect(0, 0, W, H); ctx.fillRect(0, 0, W, H);
// Smooth towards targets (decay when no new packets) // Smooth towards targets
sensorScopeRssi += (sensorScopeTargetRssi - sensorScopeRssi) * 0.25; sensorScopeRssi += (sensorScopeTargetRssi - sensorScopeRssi) * 0.25;
sensorScopeSnr += (sensorScopeTargetSnr - sensorScopeSnr) * 0.15; sensorScopeSnr += (sensorScopeTargetSnr - sensorScopeSnr) * 0.15;
// Decay targets back to zero between packets // Decay targets back to idle between updates
sensorScopeTargetRssi *= 0.97; sensorScopeTargetRssi *= 0.97;
sensorScopeTargetSnr *= 0.97; sensorScopeTargetSnr *= 0.97;
// RSSI is typically negative dBm (e.g. -0.1 to -30+) // Keep amplitude envelope for context
// Normalize: map absolute RSSI to 0-1 range (0 dB = max, -40 dB = min)
const rssiNorm = Math.min(Math.max(Math.abs(sensorScopeRssi) / 40, 0), 1.0); const rssiNorm = Math.min(Math.max(Math.abs(sensorScopeRssi) / 40, 0), 1.0);
sensorScopeHistory.push(rssiNorm); sensorScopeHistory.push(rssiNorm);
if (sensorScopeHistory.length > SENSOR_SCOPE_LEN) { if (sensorScopeHistory.length > SENSOR_SCOPE_LEN) {
sensorScopeHistory.shift(); sensorScopeHistory.shift();
} }
// Grid lines // Grid lines (horizontal + vertical)
ctx.strokeStyle = 'rgba(40, 80, 40, 0.4)'; ctx.strokeStyle = 'rgba(40, 80, 40, 0.4)';
ctx.lineWidth = 1; ctx.lineWidth = 0.8;
for (let i = 1; i < 8; i++) {
const gx = (W / 8) * i;
ctx.beginPath();
ctx.moveTo(gx, 0);
ctx.lineTo(gx, H);
ctx.stroke();
}
for (let g = 0.25; g < 1; g += 0.25) { for (let g = 0.25; g < 1; g += 0.25) {
const gy = midY - g * midY; const gy = midY - g * midY;
const gy2 = midY + g * midY; const gy2 = midY + g * midY;
@@ -4000,40 +4096,92 @@
// Center baseline // Center baseline
ctx.strokeStyle = 'rgba(60, 100, 60, 0.5)'; ctx.strokeStyle = 'rgba(60, 100, 60, 0.5)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, midY); ctx.moveTo(0, midY);
ctx.lineTo(W, midY); ctx.lineTo(W, midY);
ctx.stroke(); ctx.stroke();
// Waveform (mirrored, green theme for 433) // Slow envelope as context around baseline
const stepX = W / SENSOR_SCOPE_LEN; const envStepX = W / (SENSOR_SCOPE_LEN - 1);
ctx.strokeStyle = '#0f0'; ctx.strokeStyle = 'rgba(86, 230, 120, 0.45)';
ctx.lineWidth = 1.5; ctx.lineWidth = 1;
ctx.shadowColor = '#0f0';
ctx.shadowBlur = 4;
// Upper half
ctx.beginPath(); ctx.beginPath();
for (let i = 0; i < sensorScopeHistory.length; i++) { for (let i = 0; i < sensorScopeHistory.length; i++) {
const x = i * stepX; const x = i * envStepX;
const amp = sensorScopeHistory[i] * midY * 0.9; const amp = sensorScopeHistory[i] * midY * 0.85;
const y = midY - amp; const y = midY - amp;
if (i === 0) ctx.moveTo(x, y); if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y); else ctx.lineTo(x, y);
} }
ctx.stroke(); ctx.stroke();
// Lower half (mirror)
ctx.beginPath(); ctx.beginPath();
for (let i = 0; i < sensorScopeHistory.length; i++) { for (let i = 0; i < sensorScopeHistory.length; i++) {
const x = i * stepX; const x = i * envStepX;
const amp = sensorScopeHistory[i] * midY * 0.9; const amp = sensorScopeHistory[i] * midY * 0.85;
const y = midY + amp; const y = midY + amp;
if (i === 0) ctx.moveTo(x, y); if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y); else ctx.lineTo(x, y);
} }
ctx.stroke(); ctx.stroke();
// Actual waveform trace
const waveformPointCount = Math.min(Math.max(120, Math.floor(W / 3.2)), 420);
if (sensorScopeWaveBuffer.length > 1) {
const waveIsFresh = (performance.now() - sensorScopeLastWaveAt) < 1000;
const sourceLen = sensorScopeWaveBuffer.length;
const sourceWindow = Math.min(sourceLen, 1536);
const sourceStart = sourceLen - sourceWindow;
if (sensorScopeDisplayWave.length !== waveformPointCount) {
sensorScopeDisplayWave = new Array(waveformPointCount).fill(0);
}
for (let i = 0; i < waveformPointCount; i++) {
const a = sourceStart + Math.floor((i / waveformPointCount) * sourceWindow);
const b = sourceStart + Math.floor(((i + 1) / waveformPointCount) * sourceWindow);
const start = Math.max(sourceStart, Math.min(sourceLen - 1, a));
const end = Math.max(start + 1, Math.min(sourceLen, b));
let sum = 0;
let count = 0;
for (let j = start; j < end; j++) {
sum += sensorScopeWaveBuffer[j];
count++;
}
const targetSample = count > 0 ? (sum / count) : 0;
sensorScopeDisplayWave[i] += (targetSample - sensorScopeDisplayWave[i]) * SENSOR_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA;
}
ctx.strokeStyle = waveIsFresh ? '#40ff7a' : 'rgba(64, 255, 122, 0.45)';
ctx.lineWidth = 1.7;
ctx.shadowColor = '#40ff7a';
ctx.shadowBlur = waveIsFresh ? 6 : 2;
const stepX = waveformPointCount > 1 ? (W / (waveformPointCount - 1)) : W;
ctx.beginPath();
const firstY = midY - (sensorScopeDisplayWave[0] * midY * 0.9);
ctx.moveTo(0, firstY);
for (let i = 1; i < waveformPointCount - 1; i++) {
const x = i * stepX;
const y = midY - (sensorScopeDisplayWave[i] * midY * 0.9);
const nx = (i + 1) * stepX;
const ny = midY - (sensorScopeDisplayWave[i + 1] * midY * 0.9);
const cx = (x + nx) / 2;
const cy = (y + ny) / 2;
ctx.quadraticCurveTo(x, y, cx, cy);
}
const lastX = (waveformPointCount - 1) * stepX;
const lastY = midY - (sensorScopeDisplayWave[waveformPointCount - 1] * midY * 0.9);
ctx.lineTo(lastX, lastY);
ctx.stroke();
if (!waveIsFresh) {
for (let i = 0; i < sensorScopeDisplayWave.length; i++) {
sensorScopeDisplayWave[i] *= SENSOR_SCOPE_WAVE_IDLE_DECAY;
}
}
}
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
// SNR indicator (amber dashed line) // SNR indicator (amber dashed line)
@@ -4050,7 +4198,7 @@
ctx.setLineDash([]); ctx.setLineDash([]);
} }
// Sensor decode flash (green overlay) // Sensor decode flash
if (sensorScopeMsgBurst > 0.01) { if (sensorScopeMsgBurst > 0.01) {
ctx.fillStyle = `rgba(0, 255, 100, ${sensorScopeMsgBurst * 0.15})`; ctx.fillStyle = `rgba(0, 255, 100, ${sensorScopeMsgBurst * 0.15})`;
ctx.fillRect(0, 0, W, H); ctx.fillRect(0, 0, W, H);
@@ -4064,11 +4212,15 @@
if (rssiLabel) rssiLabel.textContent = sensorScopeRssi < -0.5 ? sensorScopeRssi.toFixed(1) : '--'; if (rssiLabel) rssiLabel.textContent = sensorScopeRssi < -0.5 ? sensorScopeRssi.toFixed(1) : '--';
if (snrLabel) snrLabel.textContent = sensorScopeSnr > 0.5 ? sensorScopeSnr.toFixed(1) : '--'; if (snrLabel) snrLabel.textContent = sensorScopeSnr > 0.5 ? sensorScopeSnr.toFixed(1) : '--';
if (statusLabel) { if (statusLabel) {
if (Math.abs(sensorScopeRssi) > 1) { const waveIsFresh = (performance.now() - sensorScopeLastWaveAt) < 1000;
statusLabel.textContent = 'SIGNAL'; if (Math.abs(sensorScopeRssi) > 1.2 && waveIsFresh) {
statusLabel.style.color = '#0f0'; statusLabel.textContent = 'DEMODULATING';
statusLabel.style.color = '#40ff7a';
} else if (Math.abs(sensorScopeRssi) > 0.6) {
statusLabel.textContent = 'CARRIER';
statusLabel.style.color = '#78ff9a';
} else { } else {
statusLabel.textContent = 'MONITORING'; statusLabel.textContent = 'QUIET';
statusLabel.style.color = '#555'; statusLabel.style.color = '#555';
} }
} }
@@ -4082,6 +4234,11 @@
sensorScopeAnim = null; sensorScopeAnim = null;
} }
sensorScopeCtx = null; sensorScopeCtx = null;
sensorScopeWaveBuffer = [];
sensorScopeDisplayWave = [];
sensorScopeHistory = [];
sensorScopeLastWaveAt = 0;
sensorScopeLastInputSample = 0;
} }
// Start sensor decoding // Start sensor decoding
@@ -4256,6 +4413,11 @@
const placeholder = output.querySelector('.placeholder'); const placeholder = output.querySelector('.placeholder');
if (placeholder) placeholder.style.display = 'none'; if (placeholder) placeholder.style.display = 'none';
// Agent polling may only return decoded packets, so derive scope updates from packet levels.
if (msg && (msg.rssi !== undefined || msg.snr !== undefined || msg.noise !== undefined)) {
applySensorScopeData(msg);
}
// Create signal card if SignalCards is available // Create signal card if SignalCards is available
if (typeof SignalCards !== 'undefined' && SignalCards.createFromSensor) { if (typeof SignalCards !== 'undefined' && SignalCards.createFromSensor) {
const card = SignalCards.createFromSensor(msg); const card = SignalCards.createFromSensor(msg);
@@ -4303,8 +4465,7 @@
if (data.type === 'sensor') { if (data.type === 'sensor') {
addSensorReading(data); addSensorReading(data);
} else if (data.type === 'scope') { } else if (data.type === 'scope') {
sensorScopeTargetRssi = data.rssi; applySensorScopeData(data);
sensorScopeTargetSnr = data.snr;
} else if (data.type === 'status') { } else if (data.type === 'status') {
if (data.text === 'stopped') { if (data.text === 'stopped') {
setSensorRunning(false); setSensorRunning(false);
@@ -4332,6 +4493,12 @@
// Flash sensor scope green on decode // Flash sensor scope green on decode
sensorScopeMsgBurst = 1.0; sensorScopeMsgBurst = 1.0;
// Fallback when no dedicated scope packet has arrived recently.
if ((data.rssi !== undefined || data.snr !== undefined || data.noise !== undefined)
&& ((performance.now() - sensorScopeLastWaveAt) > 250)) {
applySensorScopeData(data);
}
sensorCount++; sensorCount++;
document.getElementById('sensorCount').textContent = sensorCount; document.getElementById('sensorCount').textContent = sensorCount;
@@ -5208,54 +5375,111 @@
let pagerScopeCtx = null; let pagerScopeCtx = null;
let pagerScopeAnim = null; let pagerScopeAnim = null;
let pagerScopeHistory = []; let pagerScopeHistory = [];
let pagerScopeWaveBuffer = [];
let pagerScopeDisplayWave = [];
const SCOPE_HISTORY_LEN = 200; const SCOPE_HISTORY_LEN = 200;
const SCOPE_WAVE_BUFFER_LEN = 2048;
const SCOPE_WAVE_INPUT_SMOOTH_ALPHA = 0.55;
const SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA = 0.22;
const SCOPE_WAVE_IDLE_DECAY = 0.96;
let pagerScopeRms = 0; let pagerScopeRms = 0;
let pagerScopePeak = 0; let pagerScopePeak = 0;
let pagerScopeTargetRms = 0; let pagerScopeTargetRms = 0;
let pagerScopeTargetPeak = 0; let pagerScopeTargetPeak = 0;
let pagerScopeMsgBurst = 0; let pagerScopeMsgBurst = 0;
let pagerScopeLastWaveAt = 0;
let pagerScopeLastInputSample = 0;
function resizePagerScopeCanvas(canvas) {
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const width = Math.max(1, Math.floor(rect.width * dpr));
const height = Math.max(1, Math.floor(rect.height * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
}
function applyPagerScopeData(scopeData) {
if (!scopeData || typeof scopeData !== 'object') return;
pagerScopeTargetRms = Number(scopeData.rms) || 0;
pagerScopeTargetPeak = Number(scopeData.peak) || 0;
if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) {
for (const packedSample of scopeData.waveform) {
const sample = Number(packedSample);
if (!Number.isFinite(sample)) continue;
const normalized = Math.max(-127, Math.min(127, sample)) / 127;
pagerScopeLastInputSample += (normalized - pagerScopeLastInputSample) * SCOPE_WAVE_INPUT_SMOOTH_ALPHA;
pagerScopeWaveBuffer.push(pagerScopeLastInputSample);
}
if (pagerScopeWaveBuffer.length > SCOPE_WAVE_BUFFER_LEN) {
pagerScopeWaveBuffer.splice(0, pagerScopeWaveBuffer.length - SCOPE_WAVE_BUFFER_LEN);
}
pagerScopeLastWaveAt = performance.now();
}
}
function initPagerScope() { function initPagerScope() {
const canvas = document.getElementById('pagerScopeCanvas'); const canvas = document.getElementById('pagerScopeCanvas');
if (!canvas) return; if (!canvas) return;
// Set actual pixel resolution
const rect = canvas.getBoundingClientRect(); if (pagerScopeAnim) {
canvas.width = rect.width * (window.devicePixelRatio || 1); cancelAnimationFrame(pagerScopeAnim);
canvas.height = rect.height * (window.devicePixelRatio || 1); pagerScopeAnim = null;
}
resizePagerScopeCanvas(canvas);
pagerScopeCtx = canvas.getContext('2d'); pagerScopeCtx = canvas.getContext('2d');
pagerScopeHistory = new Array(SCOPE_HISTORY_LEN).fill(0); pagerScopeHistory = new Array(SCOPE_HISTORY_LEN).fill(0);
pagerScopeWaveBuffer = [];
pagerScopeDisplayWave = [];
pagerScopeRms = 0; pagerScopeRms = 0;
pagerScopePeak = 0; pagerScopePeak = 0;
pagerScopeTargetRms = 0; pagerScopeTargetRms = 0;
pagerScopeTargetPeak = 0; pagerScopeTargetPeak = 0;
pagerScopeMsgBurst = 0; pagerScopeMsgBurst = 0;
pagerScopeLastWaveAt = 0;
pagerScopeLastInputSample = 0;
drawPagerScope(); drawPagerScope();
} }
function drawPagerScope() { function drawPagerScope() {
const ctx = pagerScopeCtx; const ctx = pagerScopeCtx;
if (!ctx) return; if (!ctx) return;
resizePagerScopeCanvas(ctx.canvas);
const W = ctx.canvas.width; const W = ctx.canvas.width;
const H = ctx.canvas.height; const H = ctx.canvas.height;
const midY = H / 2; const midY = H / 2;
// Phosphor persistence: semi-transparent clear // Phosphor persistence
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)'; ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
ctx.fillRect(0, 0, W, H); ctx.fillRect(0, 0, W, H);
// Smooth towards target values // Smooth towards target values
pagerScopeRms += (pagerScopeTargetRms - pagerScopeRms) * 0.25; pagerScopeRms += (pagerScopeTargetRms - pagerScopeRms) * 0.25;
pagerScopePeak += (pagerScopeTargetPeak - pagerScopePeak) * 0.15; pagerScopePeak += (pagerScopeTargetPeak - pagerScopePeak) * 0.15;
// Push current RMS into history (normalized 0-1 against 32768) // Keep a slow amplitude envelope for readability
pagerScopeHistory.push(Math.min(pagerScopeRms / 32768, 1.0)); pagerScopeHistory.push(Math.min(pagerScopeRms / 32768, 1.0));
if (pagerScopeHistory.length > SCOPE_HISTORY_LEN) { if (pagerScopeHistory.length > SCOPE_HISTORY_LEN) {
pagerScopeHistory.shift(); pagerScopeHistory.shift();
} }
// Grid lines // Grid lines (horizontal + vertical)
ctx.strokeStyle = 'rgba(40, 40, 80, 0.4)'; ctx.strokeStyle = 'rgba(40, 40, 80, 0.4)';
ctx.lineWidth = 1; ctx.lineWidth = 0.8;
for (let i = 1; i < 8; i++) {
const gx = (W / 8) * i;
ctx.beginPath();
ctx.moveTo(gx, 0);
ctx.lineTo(gx, H);
ctx.stroke();
}
for (let g = 0.25; g < 1; g += 0.25) { for (let g = 0.25; g < 1; g += 0.25) {
const gy = midY - g * midY; const gy = midY - g * midY;
const gy2 = midY + g * midY; const gy2 = midY + g * midY;
@@ -5267,40 +5491,92 @@
// Center baseline // Center baseline
ctx.strokeStyle = 'rgba(60, 60, 100, 0.5)'; ctx.strokeStyle = 'rgba(60, 60, 100, 0.5)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, midY); ctx.moveTo(0, midY);
ctx.lineTo(W, midY); ctx.lineTo(W, midY);
ctx.stroke(); ctx.stroke();
// Waveform (mirrored) // Slow envelope as context around baseline
const stepX = W / SCOPE_HISTORY_LEN; const envStepX = W / (SCOPE_HISTORY_LEN - 1);
ctx.strokeStyle = '#0ff'; ctx.strokeStyle = 'rgba(90, 180, 255, 0.45)';
ctx.lineWidth = 1.5; ctx.lineWidth = 1;
ctx.shadowColor = '#0ff';
ctx.shadowBlur = 4;
// Upper half
ctx.beginPath(); ctx.beginPath();
for (let i = 0; i < pagerScopeHistory.length; i++) { for (let i = 0; i < pagerScopeHistory.length; i++) {
const x = i * stepX; const x = i * envStepX;
const amp = pagerScopeHistory[i] * midY * 0.9; const amp = pagerScopeHistory[i] * midY * 0.85;
const y = midY - amp; const y = midY - amp;
if (i === 0) ctx.moveTo(x, y); if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y); else ctx.lineTo(x, y);
} }
ctx.stroke(); ctx.stroke();
// Lower half (mirror)
ctx.beginPath(); ctx.beginPath();
for (let i = 0; i < pagerScopeHistory.length; i++) { for (let i = 0; i < pagerScopeHistory.length; i++) {
const x = i * stepX; const x = i * envStepX;
const amp = pagerScopeHistory[i] * midY * 0.9; const amp = pagerScopeHistory[i] * midY * 0.85;
const y = midY + amp; const y = midY + amp;
if (i === 0) ctx.moveTo(x, y); if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y); else ctx.lineTo(x, y);
} }
ctx.stroke(); ctx.stroke();
// Actual waveform from real incoming audio samples
const waveformPointCount = Math.min(Math.max(120, Math.floor(W / 3.2)), 420);
if (pagerScopeWaveBuffer.length > 1) {
const waveIsFresh = (performance.now() - pagerScopeLastWaveAt) < 700;
const sourceLen = pagerScopeWaveBuffer.length;
const sourceWindow = Math.min(sourceLen, 1536);
const sourceStart = sourceLen - sourceWindow;
if (pagerScopeDisplayWave.length !== waveformPointCount) {
pagerScopeDisplayWave = new Array(waveformPointCount).fill(0);
}
for (let i = 0; i < waveformPointCount; i++) {
const a = sourceStart + Math.floor((i / waveformPointCount) * sourceWindow);
const b = sourceStart + Math.floor(((i + 1) / waveformPointCount) * sourceWindow);
const start = Math.max(sourceStart, Math.min(sourceLen - 1, a));
const end = Math.max(start + 1, Math.min(sourceLen, b));
let sum = 0;
let count = 0;
for (let j = start; j < end; j++) {
sum += pagerScopeWaveBuffer[j];
count++;
}
const targetSample = count > 0 ? (sum / count) : 0;
pagerScopeDisplayWave[i] += (targetSample - pagerScopeDisplayWave[i]) * SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA;
}
ctx.strokeStyle = waveIsFresh ? '#2efbff' : 'rgba(46, 251, 255, 0.45)';
ctx.lineWidth = 1.7;
ctx.shadowColor = '#2efbff';
ctx.shadowBlur = waveIsFresh ? 6 : 2;
const stepX = waveformPointCount > 1 ? (W / (waveformPointCount - 1)) : W;
ctx.beginPath();
const firstY = midY - (pagerScopeDisplayWave[0] * midY * 0.9);
ctx.moveTo(0, firstY);
for (let i = 1; i < waveformPointCount - 1; i++) {
const x = i * stepX;
const y = midY - (pagerScopeDisplayWave[i] * midY * 0.9);
const nx = (i + 1) * stepX;
const ny = midY - (pagerScopeDisplayWave[i + 1] * midY * 0.9);
const cx = (x + nx) / 2;
const cy = (y + ny) / 2;
ctx.quadraticCurveTo(x, y, cx, cy);
}
const lastX = (waveformPointCount - 1) * stepX;
const lastY = midY - (pagerScopeDisplayWave[waveformPointCount - 1] * midY * 0.9);
ctx.lineTo(lastX, lastY);
ctx.stroke();
if (!waveIsFresh) {
for (let i = 0; i < pagerScopeDisplayWave.length; i++) {
pagerScopeDisplayWave[i] *= SCOPE_WAVE_IDLE_DECAY;
}
}
}
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
// Peak indicator (dashed red line) // Peak indicator (dashed red line)
@@ -5331,11 +5607,15 @@
if (rmsLabel) rmsLabel.textContent = Math.round(pagerScopeRms); if (rmsLabel) rmsLabel.textContent = Math.round(pagerScopeRms);
if (peakLabel) peakLabel.textContent = Math.round(pagerScopePeak); if (peakLabel) peakLabel.textContent = Math.round(pagerScopePeak);
if (statusLabel) { if (statusLabel) {
if (pagerScopeRms > 500) { const waveIsFresh = (performance.now() - pagerScopeLastWaveAt) < 700;
statusLabel.textContent = 'SIGNAL'; if (pagerScopeRms > 1300 && waveIsFresh) {
statusLabel.style.color = '#0f0'; statusLabel.textContent = 'DEMODULATING';
statusLabel.style.color = '#00ff88';
} else if (pagerScopeRms > 500) {
statusLabel.textContent = 'CARRIER';
statusLabel.style.color = '#2efbff';
} else { } else {
statusLabel.textContent = 'MONITORING'; statusLabel.textContent = 'QUIET';
statusLabel.style.color = '#555'; statusLabel.style.color = '#555';
} }
} }
@@ -5349,6 +5629,11 @@
pagerScopeAnim = null; pagerScopeAnim = null;
} }
pagerScopeCtx = null; pagerScopeCtx = null;
pagerScopeWaveBuffer = [];
pagerScopeDisplayWave = [];
pagerScopeHistory = [];
pagerScopeLastWaveAt = 0;
pagerScopeLastInputSample = 0;
} }
function startDecoding() { function startDecoding() {
@@ -5578,8 +5863,7 @@
} else if (payload.type === 'info') { } else if (payload.type === 'info') {
showInfo(`[${data.agent_name}] ${payload.text}`); showInfo(`[${data.agent_name}] ${payload.text}`);
} else if (payload.type === 'scope') { } else if (payload.type === 'scope') {
pagerScopeTargetRms = payload.rms; applyPagerScopeData(payload);
pagerScopeTargetPeak = payload.peak;
} }
} else if (data.type === 'keepalive') { } else if (data.type === 'keepalive') {
// Ignore keepalive messages // Ignore keepalive messages
@@ -5599,8 +5883,7 @@
} else if (data.type === 'raw') { } else if (data.type === 'raw') {
showInfo(data.text); showInfo(data.text);
} else if (data.type === 'scope') { } else if (data.type === 'scope') {
pagerScopeTargetRms = data.rms; applyPagerScopeData(data);
pagerScopeTargetPeak = data.peak;
} }
} }
}; };

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% elif offline_settings.tile_provider == 'cartodb_dark_flir' %}map-flir-enabled{% endif %}"> <html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">

View File

@@ -73,7 +73,6 @@
</div> </div>
<select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)"> <select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)">
<option value="cartodb_dark_cyan">Intercept Default</option> <option value="cartodb_dark_cyan">Intercept Default</option>
<option value="cartodb_dark_flir">FLIR Thermal</option>
<option value="cartodb_dark">CartoDB Dark</option> <option value="cartodb_dark">CartoDB Dark</option>
<option value="openstreetmap">OpenStreetMap</option> <option value="openstreetmap">OpenStreetMap</option>
<option value="cartodb_light">CartoDB Positron</option> <option value="cartodb_light">CartoDB Positron</option>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% elif offline_settings.tile_provider == 'cartodb_dark_flir' %}map-flir-enabled{% endif %}"> <html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">

47
tests/test_pager_scope.py Normal file
View File

@@ -0,0 +1,47 @@
"""Tests for pager scope waveform payload generation."""
from __future__ import annotations
import io
import queue
import struct
import threading
from routes.pager import _encode_scope_waveform, audio_relay_thread
def test_encode_scope_waveform_respects_window_and_range():
samples = (-32768, -16384, 0, 16384, 32767)
waveform = _encode_scope_waveform(samples, window_size=4)
assert len(waveform) == 4
assert waveform[0] == -64
assert waveform[1] == 0
assert waveform[2] == 64
assert waveform[3] == 127
assert max(waveform) <= 127
assert min(waveform) >= -127
def test_audio_relay_thread_emits_scope_waveform(monkeypatch):
base_samples = (0, 32767, -32768, 16384) * 512
pcm = struct.pack(f"<{len(base_samples)}h", *base_samples)
rtl_stdout = io.BytesIO(pcm)
multimon_stdin = io.BytesIO()
output_queue: queue.Queue = queue.Queue()
stop_event = threading.Event()
ticks = iter([0.0, 0.2, 0.2, 0.2])
monkeypatch.setattr("routes.pager.time.monotonic", lambda: next(ticks, 0.2))
audio_relay_thread(rtl_stdout, multimon_stdin, output_queue, stop_event)
scope_event = output_queue.get_nowait()
assert scope_event["type"] == "scope"
assert scope_event["rms"] > 0
assert scope_event["peak"] > 0
assert "waveform" in scope_event
assert len(scope_event["waveform"]) > 0
assert max(scope_event["waveform"]) <= 127
assert min(scope_event["waveform"]) >= -127

View File

@@ -0,0 +1,21 @@
"""Tests for synthesized 433 MHz scope waveform payload."""
from __future__ import annotations
from routes.sensor import _build_scope_waveform
def test_build_scope_waveform_has_expected_shape_and_bounds():
waveform = _build_scope_waveform(rssi=-8.5, snr=11.2, noise=-26.0, points=96)
assert len(waveform) == 96
assert max(waveform) <= 127
assert min(waveform) >= -127
assert any(sample != 0 for sample in waveform)
def test_build_scope_waveform_changes_with_signal_profile():
low_snr = _build_scope_waveform(rssi=-14.0, snr=2.0, noise=-12.0, points=64)
high_snr = _build_scope_waveform(rssi=-14.0, snr=20.0, noise=-12.0, points=64)
assert low_snr != high_snr

25
tests/test_sstv_scope.py Normal file
View File

@@ -0,0 +1,25 @@
"""Tests for SSTV scope waveform encoding."""
from __future__ import annotations
import numpy as np
from utils.sstv.sstv_decoder import _encode_scope_waveform
def test_encode_scope_waveform_respects_window_and_bounds():
samples = np.array([-32768, -16384, 0, 16384, 32767], dtype=np.int16)
waveform = _encode_scope_waveform(samples, window_size=4)
assert len(waveform) == 4
assert waveform[0] == -64
assert waveform[1] == 0
assert waveform[2] == 64
assert waveform[3] == 127
assert max(waveform) <= 127
assert min(waveform) >= -127
def test_encode_scope_waveform_empty_input():
waveform = _encode_scope_waveform(np.array([], dtype=np.int16))
assert waveform == []

View File

@@ -122,6 +122,17 @@ class DecodeProgress:
return result return result
def _encode_scope_waveform(raw_samples: np.ndarray, window_size: int = 256) -> list[int]:
"""Compress recent int16 PCM samples to signed 8-bit values for SSE."""
if raw_samples.size == 0:
return []
window = raw_samples[-window_size:] if raw_samples.size > window_size else raw_samples
packed = np.rint(window.astype(np.float64) / 256.0).astype(np.int16)
packed = np.clip(packed, -127, 127)
return packed.tolist()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# DopplerTracker # DopplerTracker
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -423,6 +434,7 @@ class SSTVDecoder:
# Scope: compute RMS/peak from raw int16 samples every chunk # Scope: compute RMS/peak from raw int16 samples every chunk
rms_val = int(np.sqrt(np.mean(raw_samples.astype(np.float64) ** 2))) rms_val = int(np.sqrt(np.mean(raw_samples.astype(np.float64) ** 2)))
peak_val = int(np.max(np.abs(raw_samples))) peak_val = int(np.max(np.abs(raw_samples)))
waveform = _encode_scope_waveform(raw_samples)
if image_decoder is not None: if image_decoder is not None:
# Currently decoding an image # Currently decoding an image
@@ -451,7 +463,7 @@ class SSTVDecoder:
message=f'Decoding {current_mode_name}: {pct}%', message=f'Decoding {current_mode_name}: {pct}%',
partial_image=partial_url, partial_image=partial_url,
)) ))
self._emit_scope(rms_val, peak_val, 'decoding') self._emit_scope(rms_val, peak_val, 'decoding', waveform)
if complete: if complete:
# Save image # Save image
@@ -529,7 +541,7 @@ class SSTVDecoder:
vis_state=vis_detector.state.value, vis_state=vis_detector.state.value,
)) ))
self._emit_scope(rms_val, peak_val, scope_tone) self._emit_scope(rms_val, peak_val, scope_tone, waveform)
except Exception as e: except Exception as e:
logger.error(f"Error in decode thread: {e}") logger.error(f"Error in decode thread: {e}")
@@ -762,11 +774,20 @@ class SSTVDecoder:
except Exception as e: except Exception as e:
logger.error(f"Error in progress callback: {e}") logger.error(f"Error in progress callback: {e}")
def _emit_scope(self, rms: int, peak: int, tone: str | None = None) -> None: def _emit_scope(
self,
rms: int,
peak: int,
tone: str | None = None,
waveform: list[int] | None = None,
) -> None:
"""Emit scope signal levels to callback.""" """Emit scope signal levels to callback."""
if self._callback: if self._callback:
try: try:
self._callback({'type': 'sstv_scope', 'rms': rms, 'peak': peak, 'tone': tone}) payload = {'type': 'sstv_scope', 'rms': rms, 'peak': peak, 'tone': tone}
if waveform:
payload['waveform'] = waveform
self._callback(payload)
except Exception: except Exception:
pass pass