mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 06:01:56 -07:00
chore: commit all pending changes
This commit is contained in:
+44
-29
@@ -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
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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
@@ -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
@@ -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
@@ -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,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,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,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
@@ -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,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">
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user