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
+44 -29
View File
@@ -96,7 +96,7 @@ def parse_multimon_output(line: str) -> dict[str, str] | 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."""
if not app_module.logging_enabled:
return
@@ -104,25 +104,39 @@ def log_message(msg: dict[str, Any]) -> None:
with open(app_module.log_file_path, 'a') as f:
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")
except Exception as e:
logger.error(f"Failed to log message: {e}")
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 onto *output_queue*.
"""
CHUNK = 4096 # bytes 2048 samples at 16-bit mono
INTERVAL = 0.1 # seconds between scope updates
last_scope = time.monotonic()
except Exception as e:
logger.error(f"Failed to log message: {e}")
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
if not samples:
return []
window = samples[-window_size:] if len(samples) > window_size else samples
waveform: list[int] = []
for sample in window:
# Convert int16 PCM to int8 range for lightweight transport.
packed = int(round(sample / 256))
waveform.append(max(-127, min(127, packed)))
return waveform
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:
while not stop_event.is_set():
@@ -146,15 +160,16 @@ def audio_relay_thread(
if n_samples == 0:
continue
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
peak = max(abs(s) for s in samples)
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
output_queue.put_nowait({
'type': 'scope',
'rms': rms,
'peak': peak,
})
except (struct.error, ValueError, queue.Full):
pass
peak = max(abs(s) for s in samples)
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
output_queue.put_nowait({
'type': 'scope',
'rms': rms,
'peak': peak,
'waveform': _encode_scope_waveform(samples),
})
except (struct.error, ValueError, queue.Full):
pass
except Exception as e:
logger.debug(f"Audio relay error: {e}")
finally:
+64 -25
View File
@@ -1,14 +1,15 @@
"""RTL_433 sensor monitoring routes."""
from __future__ import annotations
import json
import queue
import subprocess
import threading
import time
from datetime import datetime
from typing import Generator
from __future__ import annotations
import json
import math
import queue
import subprocess
import threading
import time
from datetime import datetime
from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response
@@ -28,12 +29,42 @@ sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used
sensor_active_device: int | None = None
# RSSI history per device (model_id -> list of (timestamp, rssi))
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
_MAX_RSSI_HISTORY = 60
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
# RSSI history per device (model_id -> list of (timestamp, rssi))
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
_MAX_RSSI_HISTORY = 60
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."""
try:
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')
snr = data.get('snr')
noise = data.get('noise')
if rssi is not None or snr is not None:
try:
app_module.sensor_queue.put_nowait({
'type': 'scope',
'rssi': rssi if rssi is not None else 0,
'snr': snr if snr is not None else 0,
'noise': noise if noise is not None else 0,
})
except queue.Full:
pass
if rssi is not None or snr is not None:
try:
rssi_value = float(rssi) if rssi is not None else 0.0
snr_value = float(snr) if snr is not None else 0.0
noise_value = float(noise) if noise is not None else 0.0
app_module.sensor_queue.put_nowait({
'type': 'scope',
'rssi': rssi_value,
'snr': snr_value,
'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
if app_module.logging_enabled:
+33 -23
View File
@@ -898,66 +898,76 @@ body {
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 650;
--target-x: 50%;
--target-y: 50%;
z-index: 1200;
--crosshair-x-start: 100%;
--crosshair-y-start: 100%;
--crosshair-x-end: 50%;
--crosshair-y-end: 50%;
--crosshair-duration: 1500ms;
}
.map-crosshair-line {
position: absolute;
opacity: 0;
background: var(--accent-cyan);
box-shadow: 0 0 10px var(--accent-cyan);
box-shadow: none;
will-change: transform, opacity;
}
.map-crosshair-vertical {
top: 0;
bottom: 0;
width: 2px;
right: 0;
transform: translateX(0);
width: 1px;
left: 0;
transform: translateX(var(--crosshair-x-start));
}
.map-crosshair-horizontal {
left: 0;
right: 0;
height: 2px;
bottom: 0;
transform: translateY(0);
height: 1px;
top: 0;
transform: translateY(var(--crosshair-y-start));
}
.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 {
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 {
0% {
transform: translateX(0);
opacity: 0.95;
transform: translateX(var(--crosshair-x-start));
opacity: 0;
}
75% {
opacity: 0.95;
12% {
opacity: 1;
}
85% {
opacity: 1;
}
100% {
transform: translateX(calc(var(--target-x) - 100%));
transform: translateX(var(--crosshair-x-end));
opacity: 0;
}
}
@keyframes mapCrosshairSweepY {
0% {
transform: translateY(0);
opacity: 0.95;
transform: translateY(var(--crosshair-y-start));
opacity: 0;
}
75% {
opacity: 0.95;
12% {
opacity: 1;
}
85% {
opacity: 1;
}
100% {
transform: translateY(calc(var(--target-y) - 100%));
transform: translateY(var(--crosshair-y-end));
opacity: 0;
}
}
@@ -965,7 +975,7 @@ body {
@media (prefers-reduced-motion: reduce) {
.map-crosshair-overlay.active .map-crosshair-vertical,
.map-crosshair-overlay.active .map-crosshair-horizontal {
animation-duration: 120ms;
animation-duration: 220ms;
}
}
-53
View File
@@ -479,10 +479,6 @@
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 */
.leaflet-container.map-theme-cyber {
position: relative;
@@ -531,55 +527,6 @@ html.map-cyber-enabled .leaflet-container::after {
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 */
@media (max-width: 960px) {
.settings-tabs {
+1 -21
View File
@@ -33,15 +33,6 @@ const Settings = {
mapTheme: 'cyber',
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: {
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>',
@@ -116,7 +107,6 @@ const Settings = {
const resolvedConfig = config || this.getTileConfig();
const themeClass = this._getMapThemeClass(resolvedConfig);
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) {
if (!config || !config.mapTheme) return null;
if (config.mapTheme === 'cyber') return 'map-theme-cyber';
if (config.mapTheme === 'flir') return 'map-theme-flir';
return null;
},
@@ -375,7 +364,7 @@ const Settings = {
container.style.background = '';
}
container.classList.remove('map-theme-cyber', 'map-theme-flir');
container.classList.remove('map-theme-cyber');
const resolvedConfig = config || this.getTileConfig();
const themeClass = this._getMapThemeClass(resolvedConfig);
@@ -392,15 +381,6 @@ const Settings = {
tilePane.style.opacity = '1';
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
+141 -23
View File
@@ -15,13 +15,21 @@ const SSTVGeneral = (function() {
let sstvGeneralScopeCtx = null;
let sstvGeneralScopeAnim = null;
let sstvGeneralScopeHistory = [];
let sstvGeneralScopeWaveBuffer = [];
let sstvGeneralScopeDisplayWave = [];
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 sstvGeneralScopePeak = 0;
let sstvGeneralScopeTargetRms = 0;
let sstvGeneralScopeTargetPeak = 0;
let sstvGeneralScopeMsgBurst = 0;
let sstvGeneralScopeTone = null;
let sstvGeneralScopeLastWaveAt = 0;
let sstvGeneralScopeLastInputSample = 0;
/**
* Initialize the SSTV General mode
@@ -205,20 +213,64 @@ const SSTVGeneral = (function() {
/**
* 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() {
const canvas = document.getElementById('sstvGeneralScopeCanvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * (window.devicePixelRatio || 1);
canvas.height = rect.height * (window.devicePixelRatio || 1);
if (sstvGeneralScopeAnim) {
cancelAnimationFrame(sstvGeneralScopeAnim);
sstvGeneralScopeAnim = null;
}
resizeSstvGeneralScopeCanvas(canvas);
sstvGeneralScopeCtx = canvas.getContext('2d');
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
sstvGeneralScopeWaveBuffer = [];
sstvGeneralScopeDisplayWave = [];
sstvGeneralScopeRms = 0;
sstvGeneralScopePeak = 0;
sstvGeneralScopeTargetRms = 0;
sstvGeneralScopeTargetPeak = 0;
sstvGeneralScopeMsgBurst = 0;
sstvGeneralScopeTone = null;
sstvGeneralScopeLastWaveAt = 0;
sstvGeneralScopeLastInputSample = 0;
drawSstvGeneralScope();
}
@@ -228,12 +280,14 @@ const SSTVGeneral = (function() {
function drawSstvGeneralScope() {
const ctx = sstvGeneralScopeCtx;
if (!ctx) return;
resizeSstvGeneralScopeCanvas(ctx.canvas);
const W = ctx.canvas.width;
const H = ctx.canvas.height;
const midY = H / 2;
// Phosphor persistence
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
ctx.fillRect(0, 0, W, H);
// Smooth towards target
@@ -256,32 +310,84 @@ const SSTVGeneral = (function() {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
// Waveform
const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
ctx.strokeStyle = '#c080ff';
ctx.lineWidth = 1.5;
ctx.shadowColor = '#c080ff';
ctx.shadowBlur = 4;
// Upper half
// Envelope
const envStepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
ctx.strokeStyle = 'rgba(168, 110, 255, 0.45)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
const x = i * envStepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.85;
const y = midY - amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Lower half (mirror)
ctx.beginPath();
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
const x = i * envStepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.85;
const y = midY + amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
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;
// Peak indicator
@@ -317,8 +423,17 @@ const SSTVGeneral = (function() {
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
}
if (statusLabel) {
if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
const waveIsFresh = (performance.now() - sstvGeneralScopeLastWaveAt) < 1000;
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);
@@ -330,6 +445,11 @@ const SSTVGeneral = (function() {
function stopSstvGeneralScope() {
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
sstvGeneralScopeCtx = null;
sstvGeneralScopeWaveBuffer = [];
sstvGeneralScopeDisplayWave = [];
sstvGeneralScopeHistory = [];
sstvGeneralScopeLastWaveAt = 0;
sstvGeneralScopeLastInputSample = 0;
}
/**
@@ -353,9 +473,7 @@ const SSTVGeneral = (function() {
if (data.type === 'sstv_progress') {
handleProgress(data);
} else if (data.type === 'sstv_scope') {
sstvGeneralScopeTargetRms = data.rms;
sstvGeneralScopeTargetPeak = data.peak;
if (data.tone !== undefined) sstvGeneralScopeTone = data.tone;
applySstvGeneralScopeData(data);
}
} catch (err) {
console.error('Failed to parse SSE message:', err);
+66 -29
View File
@@ -17,21 +17,51 @@ const STATIC_PREFIXES = [
'/static/fonts/',
];
const CACHE_EXACT = ['/manifest.json'];
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);
const CACHE_EXACT = ['/manifest.json'];
function isHttpRequest(req) {
const url = new URL(req.url);
return url.protocol === 'http:' || url.protocol === 'https:';
}
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));
}
function isStaticAsset(req) {
const url = new URL(req.url);
if (CACHE_EXACT.includes(url.pathname)) return true;
return STATIC_PREFIXES.some(p => url.pathname.startsWith(p));
}
function isStaticAsset(req) {
const url = new URL(req.url);
if (CACHE_EXACT.includes(url.pathname)) return true;
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.skipWaiting();
@@ -45,14 +75,21 @@ self.addEventListener('activate', (e) => {
);
});
self.addEventListener('fetch', (e) => {
const req = e.request;
// Always bypass service worker for non-GET and streaming routes
if (isNetworkOnly(req)) {
e.respondWith(fetch(req));
return;
}
self.addEventListener('fetch', (e) => {
const req = e.request;
// Ignore non-HTTP(S) requests so extensions/browser-internal URLs are untouched.
if (!isHttpRequest(req)) {
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
if (isStaticAsset(req)) {
@@ -65,15 +102,15 @@ self.addEventListener('fetch', (e) => {
if (res && res.status === 200) cache.put(req, res.clone());
}).catch(() => {});
return cached;
}
return fetch(req).then(res => {
if (res && res.status === 200) cache.put(req, res.clone());
return res;
});
})
)
);
return;
}
return fetch(req).then(res => {
if (res && res.status === 200) cache.put(req, res.clone());
return res;
}).catch(() => fallbackResponse(req, 504));
})
)
);
return;
}
// Network-first for HTML pages
+122 -26
View File
@@ -1,5 +1,5 @@
<!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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -423,9 +423,16 @@
let alertsEnabled = true;
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
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 mapCrosshairFallbackTimer = null;
let panelSelectionFallbackTimer = null;
let panelSelectionStageTimer = null;
let mapCrosshairRequestId = 0;
// Watchlist - persisted to localStorage
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;
const overlay = document.getElementById('mapCrosshairOverlay');
if (!overlay) return;
const point = radarMap.latLngToContainerPoint([lat, lon]);
const size = radarMap.getSize();
const targetX = Math.max(0, Math.min(size.x, point.x));
const targetY = Math.max(0, Math.min(size.y, point.y));
let targetX;
let targetY;
overlay.style.setProperty('--target-x', `${targetX}px`);
overlay.style.setProperty('--target-y', `${targetY}px`);
if (lockToMapCenter) {
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');
void overlay.offsetWidth;
overlay.classList.add('active');
@@ -2814,16 +2835,102 @@ sudo make install</code>
mapCrosshairResetTimer = setTimeout(() => {
overlay.classList.remove('active');
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') {
const prevSelected = selectedIcao;
selectedIcao = icao;
mapCrosshairRequestId += 1;
if (mapCrosshairFallbackTimer) {
clearTimeout(mapCrosshairFallbackTimer);
mapCrosshairFallbackTimer = null;
if (panelSelectionFallbackTimer) {
clearTimeout(panelSelectionFallbackTimer);
panelSelectionFallbackTimer = null;
}
if (panelSelectionStageTimer) {
clearTimeout(panelSelectionStageTimer);
panelSelectionStageTimer = null;
}
// Update marker icons for both previous and new selection
@@ -2853,19 +2960,8 @@ sudo make install</code>
const targetLon = ac.lon;
if (source === 'panel' && radarMap) {
const requestId = mapCrosshairRequestId;
let crosshairTriggered = false;
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);
runPanelSelectionAnimation(targetLat, targetLon, mapCrosshairRequestId);
return;
}
radarMap.setView([targetLat, targetLon], 10);
+1 -1
View File
@@ -1,5 +1,5 @@
<!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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+1 -1
View File
@@ -1,5 +1,5 @@
<!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>
<meta charset="UTF-8">
+1 -1
View File
@@ -1,5 +1,5 @@
<!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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+352 -69
View File
@@ -1,5 +1,5 @@
<!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>
<meta charset="UTF-8">
@@ -2193,7 +2193,7 @@
<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="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;">
<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>
@@ -2461,7 +2461,7 @@
<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="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;">
<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>
@@ -2835,7 +2835,7 @@
<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="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;">
<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>
@@ -2853,7 +2853,7 @@
<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="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;">
<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>
@@ -3934,61 +3934,157 @@
let sensorScopeCtx = null;
let sensorScopeAnim = null;
let sensorScopeHistory = [];
let sensorScopeWaveBuffer = [];
let sensorScopeDisplayWave = [];
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 sensorScopeSnr = 0;
let sensorScopeTargetRssi = 0;
let sensorScopeTargetSnr = 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() {
const canvas = document.getElementById('sensorScopeCanvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * (window.devicePixelRatio || 1);
canvas.height = rect.height * (window.devicePixelRatio || 1);
if (sensorScopeAnim) {
cancelAnimationFrame(sensorScopeAnim);
sensorScopeAnim = null;
}
resizeSensorScopeCanvas(canvas);
sensorScopeCtx = canvas.getContext('2d');
sensorScopeHistory = new Array(SENSOR_SCOPE_LEN).fill(0);
sensorScopeWaveBuffer = [];
sensorScopeDisplayWave = [];
sensorScopeRssi = 0;
sensorScopeSnr = 0;
sensorScopeTargetRssi = 0;
sensorScopeTargetSnr = 0;
sensorScopeMsgBurst = 0;
sensorScopeLastPulse = 0;
sensorScopeLastWaveAt = 0;
sensorScopeLastInputSample = 0;
drawSensorScope();
}
function drawSensorScope() {
const ctx = sensorScopeCtx;
if (!ctx) return;
resizeSensorScopeCanvas(ctx.canvas);
const W = ctx.canvas.width;
const H = ctx.canvas.height;
const midY = H / 2;
// Phosphor persistence
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
ctx.fillRect(0, 0, W, H);
// Smooth towards targets (decay when no new packets)
// Smooth towards targets
sensorScopeRssi += (sensorScopeTargetRssi - sensorScopeRssi) * 0.25;
sensorScopeSnr += (sensorScopeTargetSnr - sensorScopeSnr) * 0.15;
// Decay targets back to zero between packets
// Decay targets back to idle between updates
sensorScopeTargetRssi *= 0.97;
sensorScopeTargetSnr *= 0.97;
// RSSI is typically negative dBm (e.g. -0.1 to -30+)
// Normalize: map absolute RSSI to 0-1 range (0 dB = max, -40 dB = min)
// Keep amplitude envelope for context
const rssiNorm = Math.min(Math.max(Math.abs(sensorScopeRssi) / 40, 0), 1.0);
sensorScopeHistory.push(rssiNorm);
if (sensorScopeHistory.length > SENSOR_SCOPE_LEN) {
sensorScopeHistory.shift();
}
// Grid lines
// Grid lines (horizontal + vertical)
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) {
const gy = midY - g * midY;
const gy2 = midY + g * midY;
@@ -4000,40 +4096,92 @@
// Center baseline
ctx.strokeStyle = 'rgba(60, 100, 60, 0.5)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, midY);
ctx.lineTo(W, midY);
ctx.stroke();
// Waveform (mirrored, green theme for 433)
const stepX = W / SENSOR_SCOPE_LEN;
ctx.strokeStyle = '#0f0';
ctx.lineWidth = 1.5;
ctx.shadowColor = '#0f0';
ctx.shadowBlur = 4;
// Upper half
// Slow envelope as context around baseline
const envStepX = W / (SENSOR_SCOPE_LEN - 1);
ctx.strokeStyle = 'rgba(86, 230, 120, 0.45)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < sensorScopeHistory.length; i++) {
const x = i * stepX;
const amp = sensorScopeHistory[i] * midY * 0.9;
const x = i * envStepX;
const amp = sensorScopeHistory[i] * midY * 0.85;
const y = midY - amp;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Lower half (mirror)
ctx.beginPath();
for (let i = 0; i < sensorScopeHistory.length; i++) {
const x = i * stepX;
const amp = sensorScopeHistory[i] * midY * 0.9;
const x = i * envStepX;
const amp = sensorScopeHistory[i] * midY * 0.85;
const y = midY + amp;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
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;
// SNR indicator (amber dashed line)
@@ -4050,7 +4198,7 @@
ctx.setLineDash([]);
}
// Sensor decode flash (green overlay)
// Sensor decode flash
if (sensorScopeMsgBurst > 0.01) {
ctx.fillStyle = `rgba(0, 255, 100, ${sensorScopeMsgBurst * 0.15})`;
ctx.fillRect(0, 0, W, H);
@@ -4064,11 +4212,15 @@
if (rssiLabel) rssiLabel.textContent = sensorScopeRssi < -0.5 ? sensorScopeRssi.toFixed(1) : '--';
if (snrLabel) snrLabel.textContent = sensorScopeSnr > 0.5 ? sensorScopeSnr.toFixed(1) : '--';
if (statusLabel) {
if (Math.abs(sensorScopeRssi) > 1) {
statusLabel.textContent = 'SIGNAL';
statusLabel.style.color = '#0f0';
const waveIsFresh = (performance.now() - sensorScopeLastWaveAt) < 1000;
if (Math.abs(sensorScopeRssi) > 1.2 && waveIsFresh) {
statusLabel.textContent = 'DEMODULATING';
statusLabel.style.color = '#40ff7a';
} else if (Math.abs(sensorScopeRssi) > 0.6) {
statusLabel.textContent = 'CARRIER';
statusLabel.style.color = '#78ff9a';
} else {
statusLabel.textContent = 'MONITORING';
statusLabel.textContent = 'QUIET';
statusLabel.style.color = '#555';
}
}
@@ -4082,6 +4234,11 @@
sensorScopeAnim = null;
}
sensorScopeCtx = null;
sensorScopeWaveBuffer = [];
sensorScopeDisplayWave = [];
sensorScopeHistory = [];
sensorScopeLastWaveAt = 0;
sensorScopeLastInputSample = 0;
}
// Start sensor decoding
@@ -4256,6 +4413,11 @@
const placeholder = output.querySelector('.placeholder');
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
if (typeof SignalCards !== 'undefined' && SignalCards.createFromSensor) {
const card = SignalCards.createFromSensor(msg);
@@ -4303,8 +4465,7 @@
if (data.type === 'sensor') {
addSensorReading(data);
} else if (data.type === 'scope') {
sensorScopeTargetRssi = data.rssi;
sensorScopeTargetSnr = data.snr;
applySensorScopeData(data);
} else if (data.type === 'status') {
if (data.text === 'stopped') {
setSensorRunning(false);
@@ -4332,6 +4493,12 @@
// Flash sensor scope green on decode
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++;
document.getElementById('sensorCount').textContent = sensorCount;
@@ -5208,54 +5375,111 @@
let pagerScopeCtx = null;
let pagerScopeAnim = null;
let pagerScopeHistory = [];
let pagerScopeWaveBuffer = [];
let pagerScopeDisplayWave = [];
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 pagerScopePeak = 0;
let pagerScopeTargetRms = 0;
let pagerScopeTargetPeak = 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() {
const canvas = document.getElementById('pagerScopeCanvas');
if (!canvas) return;
// Set actual pixel resolution
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * (window.devicePixelRatio || 1);
canvas.height = rect.height * (window.devicePixelRatio || 1);
if (pagerScopeAnim) {
cancelAnimationFrame(pagerScopeAnim);
pagerScopeAnim = null;
}
resizePagerScopeCanvas(canvas);
pagerScopeCtx = canvas.getContext('2d');
pagerScopeHistory = new Array(SCOPE_HISTORY_LEN).fill(0);
pagerScopeWaveBuffer = [];
pagerScopeDisplayWave = [];
pagerScopeRms = 0;
pagerScopePeak = 0;
pagerScopeTargetRms = 0;
pagerScopeTargetPeak = 0;
pagerScopeMsgBurst = 0;
pagerScopeLastWaveAt = 0;
pagerScopeLastInputSample = 0;
drawPagerScope();
}
function drawPagerScope() {
const ctx = pagerScopeCtx;
if (!ctx) return;
resizePagerScopeCanvas(ctx.canvas);
const W = ctx.canvas.width;
const H = ctx.canvas.height;
const midY = H / 2;
// Phosphor persistence: semi-transparent clear
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
// Phosphor persistence
ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
ctx.fillRect(0, 0, W, H);
// Smooth towards target values
pagerScopeRms += (pagerScopeTargetRms - pagerScopeRms) * 0.25;
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));
if (pagerScopeHistory.length > SCOPE_HISTORY_LEN) {
pagerScopeHistory.shift();
}
// Grid lines
// Grid lines (horizontal + vertical)
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) {
const gy = midY - g * midY;
const gy2 = midY + g * midY;
@@ -5267,40 +5491,92 @@
// Center baseline
ctx.strokeStyle = 'rgba(60, 60, 100, 0.5)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, midY);
ctx.lineTo(W, midY);
ctx.stroke();
// Waveform (mirrored)
const stepX = W / SCOPE_HISTORY_LEN;
ctx.strokeStyle = '#0ff';
ctx.lineWidth = 1.5;
ctx.shadowColor = '#0ff';
ctx.shadowBlur = 4;
// Upper half
// Slow envelope as context around baseline
const envStepX = W / (SCOPE_HISTORY_LEN - 1);
ctx.strokeStyle = 'rgba(90, 180, 255, 0.45)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < pagerScopeHistory.length; i++) {
const x = i * stepX;
const amp = pagerScopeHistory[i] * midY * 0.9;
const x = i * envStepX;
const amp = pagerScopeHistory[i] * midY * 0.85;
const y = midY - amp;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Lower half (mirror)
ctx.beginPath();
for (let i = 0; i < pagerScopeHistory.length; i++) {
const x = i * stepX;
const amp = pagerScopeHistory[i] * midY * 0.9;
const x = i * envStepX;
const amp = pagerScopeHistory[i] * midY * 0.85;
const y = midY + amp;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
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;
// Peak indicator (dashed red line)
@@ -5331,11 +5607,15 @@
if (rmsLabel) rmsLabel.textContent = Math.round(pagerScopeRms);
if (peakLabel) peakLabel.textContent = Math.round(pagerScopePeak);
if (statusLabel) {
if (pagerScopeRms > 500) {
statusLabel.textContent = 'SIGNAL';
statusLabel.style.color = '#0f0';
const waveIsFresh = (performance.now() - pagerScopeLastWaveAt) < 700;
if (pagerScopeRms > 1300 && waveIsFresh) {
statusLabel.textContent = 'DEMODULATING';
statusLabel.style.color = '#00ff88';
} else if (pagerScopeRms > 500) {
statusLabel.textContent = 'CARRIER';
statusLabel.style.color = '#2efbff';
} else {
statusLabel.textContent = 'MONITORING';
statusLabel.textContent = 'QUIET';
statusLabel.style.color = '#555';
}
}
@@ -5349,6 +5629,11 @@
pagerScopeAnim = null;
}
pagerScopeCtx = null;
pagerScopeWaveBuffer = [];
pagerScopeDisplayWave = [];
pagerScopeHistory = [];
pagerScopeLastWaveAt = 0;
pagerScopeLastInputSample = 0;
}
function startDecoding() {
@@ -5578,8 +5863,7 @@
} else if (payload.type === 'info') {
showInfo(`[${data.agent_name}] ${payload.text}`);
} else if (payload.type === 'scope') {
pagerScopeTargetRms = payload.rms;
pagerScopeTargetPeak = payload.peak;
applyPagerScopeData(payload);
}
} else if (data.type === 'keepalive') {
// Ignore keepalive messages
@@ -5599,8 +5883,7 @@
} else if (data.type === 'raw') {
showInfo(data.text);
} else if (data.type === 'scope') {
pagerScopeTargetRms = data.rms;
pagerScopeTargetPeak = data.peak;
applyPagerScopeData(data);
}
}
};
+1 -1
View File
@@ -1,5 +1,5 @@
<!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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-1
View File
@@ -73,7 +73,6 @@
</div>
<select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)">
<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="openstreetmap">OpenStreetMap</option>
<option value="cartodb_light">CartoDB Positron</option>
+1 -1
View File
@@ -1,5 +1,5 @@
<!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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+47
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
+21
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
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 == []
+25 -4
View File
@@ -122,6 +122,17 @@ class DecodeProgress:
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
# ---------------------------------------------------------------------------
@@ -423,6 +434,7 @@ class SSTVDecoder:
# Scope: compute RMS/peak from raw int16 samples every chunk
rms_val = int(np.sqrt(np.mean(raw_samples.astype(np.float64) ** 2)))
peak_val = int(np.max(np.abs(raw_samples)))
waveform = _encode_scope_waveform(raw_samples)
if image_decoder is not None:
# Currently decoding an image
@@ -451,7 +463,7 @@ class SSTVDecoder:
message=f'Decoding {current_mode_name}: {pct}%',
partial_image=partial_url,
))
self._emit_scope(rms_val, peak_val, 'decoding')
self._emit_scope(rms_val, peak_val, 'decoding', waveform)
if complete:
# Save image
@@ -529,7 +541,7 @@ class SSTVDecoder:
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:
logger.error(f"Error in decode thread: {e}")
@@ -762,11 +774,20 @@ class SSTVDecoder:
except Exception as 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."""
if self._callback:
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:
pass