mirror of
https://github.com/smittix/intercept.git
synced 2026-05-17 13:24:50 -07:00
Merge upstream main: add DMR, WebSDR, HF SSTV, alerts, recordings, waterfall
Merges upstream changes into fork while preserving weather satellite (NOAA APT/Meteor LRPT via SatDump), rtlamr, multi-arch build, and decoder console features from our branch. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -366,7 +366,10 @@ const BluetoothMode = (function() {
|
||||
// Badges
|
||||
const badgesEl = document.getElementById('btDetailBadges');
|
||||
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
|
||||
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
|
||||
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
|
||||
if (device.seen_before) {
|
||||
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
|
||||
}
|
||||
|
||||
// Tracker badge
|
||||
if (device.is_tracker) {
|
||||
@@ -448,12 +451,14 @@ const BluetoothMode = (function() {
|
||||
? minMax[0] + '/' + minMax[1]
|
||||
: '--';
|
||||
|
||||
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
|
||||
? new Date(device.first_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
document.getElementById('btDetailLastSeen').textContent = device.last_seen
|
||||
? new Date(device.last_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
|
||||
? new Date(device.first_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
document.getElementById('btDetailLastSeen').textContent = device.last_seen
|
||||
? new Date(device.last_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
|
||||
updateWatchlistButton(device);
|
||||
|
||||
// Services
|
||||
const servicesContainer = document.getElementById('btDetailServices');
|
||||
@@ -465,13 +470,29 @@ const BluetoothMode = (function() {
|
||||
servicesContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Show content, hide placeholder
|
||||
placeholder.style.display = 'none';
|
||||
content.style.display = 'block';
|
||||
// Show content, hide placeholder
|
||||
placeholder.style.display = 'none';
|
||||
content.style.display = 'block';
|
||||
|
||||
// Highlight selected device in list
|
||||
highlightSelectedDevice(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update watchlist button state
|
||||
*/
|
||||
function updateWatchlistButton(device) {
|
||||
const btn = document.getElementById('btDetailWatchBtn');
|
||||
if (!btn) return;
|
||||
if (typeof AlertCenter === 'undefined') {
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
btn.style.display = '';
|
||||
const watchlisted = AlertCenter.isWatchlisted(device.address);
|
||||
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
|
||||
btn.classList.toggle('active', watchlisted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear device selection
|
||||
@@ -525,24 +546,43 @@ const BluetoothMode = (function() {
|
||||
/**
|
||||
* Copy selected device address to clipboard
|
||||
*/
|
||||
function copyAddress() {
|
||||
if (!selectedDeviceId) return;
|
||||
const device = devices.get(selectedDeviceId);
|
||||
if (!device) return;
|
||||
function copyAddress() {
|
||||
if (!selectedDeviceId) return;
|
||||
const device = devices.get(selectedDeviceId);
|
||||
if (!device) return;
|
||||
|
||||
navigator.clipboard.writeText(device.address).then(() => {
|
||||
const btn = document.querySelector('.bt-detail-btn');
|
||||
if (btn) {
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
btn.style.background = '#22c55e';
|
||||
navigator.clipboard.writeText(device.address).then(() => {
|
||||
const btn = document.getElementById('btDetailCopyBtn');
|
||||
if (btn) {
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
btn.style.background = '#22c55e';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.style.background = '';
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle Bluetooth watchlist for selected device
|
||||
*/
|
||||
function toggleWatchlist() {
|
||||
if (!selectedDeviceId) return;
|
||||
const device = devices.get(selectedDeviceId);
|
||||
if (!device || typeof AlertCenter === 'undefined') return;
|
||||
|
||||
if (AlertCenter.isWatchlisted(device.address)) {
|
||||
AlertCenter.removeBluetoothWatchlist(device.address);
|
||||
showInfo('Removed from watchlist');
|
||||
} else {
|
||||
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
|
||||
showInfo('Added to watchlist');
|
||||
}
|
||||
|
||||
setTimeout(() => updateWatchlistButton(device), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a device - opens modal with details
|
||||
@@ -1090,10 +1130,11 @@ const BluetoothMode = (function() {
|
||||
const isNew = !inBaseline;
|
||||
const hasName = !!device.name;
|
||||
const isTracker = device.is_tracker === true;
|
||||
const trackerType = device.tracker_type;
|
||||
const trackerConfidence = device.tracker_confidence;
|
||||
const riskScore = device.risk_score || 0;
|
||||
const agentName = device._agent || 'Local';
|
||||
const trackerType = device.tracker_type;
|
||||
const trackerConfidence = device.tracker_confidence;
|
||||
const riskScore = device.risk_score || 0;
|
||||
const agentName = device._agent || 'Local';
|
||||
const seenBefore = device.seen_before === true;
|
||||
|
||||
// Calculate RSSI bar width (0-100%)
|
||||
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
||||
@@ -1145,8 +1186,9 @@ const BluetoothMode = (function() {
|
||||
|
||||
// Build secondary info line
|
||||
let secondaryParts = [addr];
|
||||
if (mfr) secondaryParts.push(mfr);
|
||||
secondaryParts.push('Seen ' + seenCount + '×');
|
||||
if (mfr) secondaryParts.push(mfr);
|
||||
secondaryParts.push('Seen ' + seenCount + '×');
|
||||
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
||||
// Add agent name if not Local
|
||||
if (agentName !== 'Local') {
|
||||
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
|
||||
@@ -1358,9 +1400,10 @@ const BluetoothMode = (function() {
|
||||
setBaseline,
|
||||
clearBaseline,
|
||||
exportData,
|
||||
selectDevice,
|
||||
clearSelection,
|
||||
copyAddress,
|
||||
selectDevice,
|
||||
clearSelection,
|
||||
copyAddress,
|
||||
toggleWatchlist,
|
||||
|
||||
// Agent handling
|
||||
handleAgentChange,
|
||||
|
||||
504
static/js/modes/dmr.js
Normal file
504
static/js/modes/dmr.js
Normal file
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* Intercept - DMR / Digital Voice Mode
|
||||
* Decoding DMR, P25, NXDN, D-STAR digital voice protocols
|
||||
*/
|
||||
|
||||
// ============== STATE ==============
|
||||
let isDmrRunning = false;
|
||||
let dmrEventSource = null;
|
||||
let dmrCallCount = 0;
|
||||
let dmrSyncCount = 0;
|
||||
let dmrCallHistory = [];
|
||||
let dmrCurrentProtocol = '--';
|
||||
|
||||
// ============== SYNTHESIZER STATE ==============
|
||||
let dmrSynthCanvas = null;
|
||||
let dmrSynthCtx = null;
|
||||
let dmrSynthBars = [];
|
||||
let dmrSynthAnimationId = null;
|
||||
let dmrSynthInitialized = false;
|
||||
let dmrActivityLevel = 0;
|
||||
let dmrActivityTarget = 0;
|
||||
let dmrEventType = 'idle';
|
||||
let dmrLastEventTime = 0;
|
||||
const DMR_BAR_COUNT = 48;
|
||||
const DMR_DECAY_RATE = 0.015;
|
||||
const DMR_BURST_SYNC = 0.6;
|
||||
const DMR_BURST_CALL = 0.85;
|
||||
const DMR_BURST_VOICE = 0.95;
|
||||
|
||||
// ============== TOOLS CHECK ==============
|
||||
|
||||
function checkDmrTools() {
|
||||
fetch('/dmr/tools')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const warning = document.getElementById('dmrToolsWarning');
|
||||
const warningText = document.getElementById('dmrToolsWarningText');
|
||||
if (!warning) return;
|
||||
|
||||
const missing = [];
|
||||
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
|
||||
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
|
||||
|
||||
if (missing.length > 0) {
|
||||
warning.style.display = 'block';
|
||||
if (warningText) warningText.textContent = missing.join(', ');
|
||||
} else {
|
||||
warning.style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ============== START / STOP ==============
|
||||
|
||||
function startDmr() {
|
||||
const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625);
|
||||
const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
|
||||
const gain = parseInt(document.getElementById('dmrGain')?.value || 40);
|
||||
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
||||
|
||||
// Check device availability before starting
|
||||
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('dmr')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/dmr/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ frequency, protocol, gain, device })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'started') {
|
||||
isDmrRunning = true;
|
||||
dmrCallCount = 0;
|
||||
dmrSyncCount = 0;
|
||||
dmrCallHistory = [];
|
||||
updateDmrUI();
|
||||
connectDmrSSE();
|
||||
dmrEventType = 'idle';
|
||||
dmrActivityTarget = 0.1;
|
||||
dmrLastEventTime = Date.now();
|
||||
if (!dmrSynthInitialized) initDmrSynthesizer();
|
||||
updateDmrSynthStatus();
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'DECODING';
|
||||
if (typeof reserveDevice === 'function') {
|
||||
reserveDevice(parseInt(device), 'dmr');
|
||||
}
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('DMR', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
|
||||
}
|
||||
} else {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Error', data.message || 'Failed to start DMR');
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[DMR] Start error:', err));
|
||||
}
|
||||
|
||||
function stopDmr() {
|
||||
fetch('/dmr/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
isDmrRunning = false;
|
||||
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
|
||||
updateDmrUI();
|
||||
dmrEventType = 'stopped';
|
||||
dmrActivityTarget = 0;
|
||||
updateDmrSynthStatus();
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||
if (typeof releaseDevice === 'function') {
|
||||
releaseDevice('dmr');
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[DMR] Stop error:', err));
|
||||
}
|
||||
|
||||
// ============== SSE STREAMING ==============
|
||||
|
||||
function connectDmrSSE() {
|
||||
if (dmrEventSource) dmrEventSource.close();
|
||||
dmrEventSource = new EventSource('/dmr/stream');
|
||||
|
||||
dmrEventSource.onmessage = function(event) {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleDmrMessage(msg);
|
||||
};
|
||||
|
||||
dmrEventSource.onerror = function() {
|
||||
if (isDmrRunning) {
|
||||
setTimeout(connectDmrSSE, 2000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleDmrMessage(msg) {
|
||||
if (dmrSynthInitialized) dmrSynthPulse(msg.type);
|
||||
|
||||
if (msg.type === 'sync') {
|
||||
dmrCurrentProtocol = msg.protocol || '--';
|
||||
const protocolEl = document.getElementById('dmrActiveProtocol');
|
||||
if (protocolEl) protocolEl.textContent = dmrCurrentProtocol;
|
||||
const mainProtocolEl = document.getElementById('dmrMainProtocol');
|
||||
if (mainProtocolEl) mainProtocolEl.textContent = dmrCurrentProtocol;
|
||||
dmrSyncCount++;
|
||||
const syncCountEl = document.getElementById('dmrSyncCount');
|
||||
if (syncCountEl) syncCountEl.textContent = dmrSyncCount;
|
||||
} else if (msg.type === 'call') {
|
||||
dmrCallCount++;
|
||||
const countEl = document.getElementById('dmrCallCount');
|
||||
if (countEl) countEl.textContent = dmrCallCount;
|
||||
const mainCountEl = document.getElementById('dmrMainCallCount');
|
||||
if (mainCountEl) mainCountEl.textContent = dmrCallCount;
|
||||
|
||||
// Update current call display
|
||||
const slotInfo = msg.slot != null ? `
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: var(--text-muted);">Slot</span>
|
||||
<span style="color: var(--accent-orange); font-family: var(--font-mono);">${msg.slot}</span>
|
||||
</div>` : '';
|
||||
const callEl = document.getElementById('dmrCurrentCall');
|
||||
if (callEl) {
|
||||
callEl.innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: var(--text-muted);">Talkgroup</span>
|
||||
<span style="color: var(--accent-green); font-weight: bold; font-family: var(--font-mono);">${msg.talkgroup}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: var(--text-muted);">Source ID</span>
|
||||
<span style="color: var(--accent-cyan); font-family: var(--font-mono);">${msg.source_id}</span>
|
||||
</div>${slotInfo}
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: var(--text-muted);">Time</span>
|
||||
<span style="color: var(--text-primary);">${msg.timestamp}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add to history
|
||||
dmrCallHistory.unshift({
|
||||
talkgroup: msg.talkgroup,
|
||||
source_id: msg.source_id,
|
||||
protocol: dmrCurrentProtocol,
|
||||
time: msg.timestamp,
|
||||
});
|
||||
if (dmrCallHistory.length > 50) dmrCallHistory.length = 50;
|
||||
renderDmrHistory();
|
||||
|
||||
} else if (msg.type === 'slot') {
|
||||
// Update slot info in current call
|
||||
} else if (msg.type === 'raw') {
|
||||
// Raw DSD output — triggers synthesizer activity via dmrSynthPulse
|
||||
} else if (msg.type === 'heartbeat') {
|
||||
// Decoder is alive and listening — keep synthesizer in listening state
|
||||
if (isDmrRunning && dmrSynthInitialized) {
|
||||
if (dmrEventType === 'idle' || dmrEventType === 'raw') {
|
||||
dmrEventType = 'raw';
|
||||
dmrActivityTarget = Math.max(dmrActivityTarget, 0.15);
|
||||
dmrLastEventTime = Date.now();
|
||||
updateDmrSynthStatus();
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'status') {
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (msg.text === 'started') {
|
||||
if (statusEl) statusEl.textContent = 'DECODING';
|
||||
} else if (msg.text === 'crashed') {
|
||||
isDmrRunning = false;
|
||||
updateDmrUI();
|
||||
dmrEventType = 'stopped';
|
||||
dmrActivityTarget = 0;
|
||||
updateDmrSynthStatus();
|
||||
if (statusEl) statusEl.textContent = 'CRASHED';
|
||||
if (typeof releaseDevice === 'function') releaseDevice('dmr');
|
||||
const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`;
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('DMR Error', detail);
|
||||
}
|
||||
} else if (msg.text === 'stopped') {
|
||||
isDmrRunning = false;
|
||||
updateDmrUI();
|
||||
dmrEventType = 'stopped';
|
||||
dmrActivityTarget = 0;
|
||||
updateDmrSynthStatus();
|
||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||
if (typeof releaseDevice === 'function') releaseDevice('dmr');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============== UI ==============
|
||||
|
||||
function updateDmrUI() {
|
||||
const startBtn = document.getElementById('startDmrBtn');
|
||||
const stopBtn = document.getElementById('stopDmrBtn');
|
||||
if (startBtn) startBtn.style.display = isDmrRunning ? 'none' : 'block';
|
||||
if (stopBtn) stopBtn.style.display = isDmrRunning ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function renderDmrHistory() {
|
||||
const container = document.getElementById('dmrHistoryBody');
|
||||
if (!container) return;
|
||||
|
||||
const historyCountEl = document.getElementById('dmrHistoryCount');
|
||||
if (historyCountEl) historyCountEl.textContent = `${dmrCallHistory.length} calls`;
|
||||
|
||||
if (dmrCallHistory.length === 0) {
|
||||
container.innerHTML = '<tr><td colspan="4" style="padding: 10px; text-align: center; color: var(--text-muted);">No calls recorded</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = dmrCallHistory.slice(0, 20).map(call => `
|
||||
<tr>
|
||||
<td style="padding: 3px 6px; font-family: var(--font-mono);">${call.time}</td>
|
||||
<td style="padding: 3px 6px; color: var(--accent-green);">${call.talkgroup}</td>
|
||||
<td style="padding: 3px 6px; color: var(--accent-cyan);">${call.source_id}</td>
|
||||
<td style="padding: 3px 6px;">${call.protocol}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ============== SYNTHESIZER ==============
|
||||
|
||||
function initDmrSynthesizer() {
|
||||
dmrSynthCanvas = document.getElementById('dmrSynthCanvas');
|
||||
if (!dmrSynthCanvas) return;
|
||||
|
||||
// Use the canvas element's own rendered size for the backing buffer
|
||||
const rect = dmrSynthCanvas.getBoundingClientRect();
|
||||
const w = Math.round(rect.width) || 600;
|
||||
const h = Math.round(rect.height) || 70;
|
||||
dmrSynthCanvas.width = w;
|
||||
dmrSynthCanvas.height = h;
|
||||
|
||||
dmrSynthCtx = dmrSynthCanvas.getContext('2d');
|
||||
|
||||
dmrSynthBars = [];
|
||||
for (let i = 0; i < DMR_BAR_COUNT; i++) {
|
||||
dmrSynthBars[i] = { height: 2, targetHeight: 2, velocity: 0 };
|
||||
}
|
||||
|
||||
dmrActivityLevel = 0;
|
||||
dmrActivityTarget = 0;
|
||||
dmrEventType = isDmrRunning ? 'idle' : 'stopped';
|
||||
dmrSynthInitialized = true;
|
||||
|
||||
updateDmrSynthStatus();
|
||||
|
||||
if (dmrSynthAnimationId) cancelAnimationFrame(dmrSynthAnimationId);
|
||||
drawDmrSynthesizer();
|
||||
}
|
||||
|
||||
function drawDmrSynthesizer() {
|
||||
if (!dmrSynthCtx || !dmrSynthCanvas) return;
|
||||
|
||||
const width = dmrSynthCanvas.width;
|
||||
const height = dmrSynthCanvas.height;
|
||||
const barWidth = (width / DMR_BAR_COUNT) - 2;
|
||||
const now = Date.now();
|
||||
|
||||
// Clear canvas
|
||||
dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
||||
dmrSynthCtx.fillRect(0, 0, width, height);
|
||||
|
||||
// Decay activity toward target
|
||||
const timeSinceEvent = now - dmrLastEventTime;
|
||||
if (timeSinceEvent > 2000) {
|
||||
// No events for 2s — decay target toward idle
|
||||
dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE);
|
||||
if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') {
|
||||
dmrEventType = 'idle';
|
||||
updateDmrSynthStatus();
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth approach to target
|
||||
dmrActivityLevel += (dmrActivityTarget - dmrActivityLevel) * 0.08;
|
||||
|
||||
// Determine effective activity (idle breathing when stopped/idle)
|
||||
let effectiveActivity = dmrActivityLevel;
|
||||
if (dmrEventType === 'stopped') {
|
||||
effectiveActivity = 0;
|
||||
} else if (effectiveActivity < 0.1 && isDmrRunning) {
|
||||
// Visible idle breathing — shows decoder is alive and listening
|
||||
effectiveActivity = 0.12 + Math.sin(now / 1000) * 0.06;
|
||||
}
|
||||
|
||||
// Ripple timing for sync events
|
||||
const syncRippleAge = (dmrEventType === 'sync' && timeSinceEvent < 500) ? 1 - (timeSinceEvent / 500) : 0;
|
||||
// Voice ripple overlay
|
||||
const voiceRipple = (dmrEventType === 'voice') ? Math.sin(now / 60) * 0.15 : 0;
|
||||
|
||||
// Update bar targets and physics
|
||||
for (let i = 0; i < DMR_BAR_COUNT; i++) {
|
||||
const time = now / 200;
|
||||
const wave1 = Math.sin(time + i * 0.3) * 0.2;
|
||||
const wave2 = Math.sin(time * 1.7 + i * 0.5) * 0.15;
|
||||
const randomAmount = 0.05 + effectiveActivity * 0.25;
|
||||
const random = (Math.random() - 0.5) * randomAmount;
|
||||
|
||||
// Bell curve — center bars taller
|
||||
const centerDist = Math.abs(i - DMR_BAR_COUNT / 2) / (DMR_BAR_COUNT / 2);
|
||||
const centerBoost = 1 - centerDist * 0.5;
|
||||
|
||||
// Sync ripple: center-outward wave burst
|
||||
let rippleBoost = 0;
|
||||
if (syncRippleAge > 0) {
|
||||
const ripplePos = (1 - syncRippleAge) * DMR_BAR_COUNT / 2;
|
||||
const distFromRipple = Math.abs(i - DMR_BAR_COUNT / 2) - ripplePos;
|
||||
rippleBoost = Math.max(0, 1 - Math.abs(distFromRipple) / 4) * syncRippleAge * 0.4;
|
||||
}
|
||||
|
||||
const baseHeight = 0.1 + effectiveActivity * 0.55;
|
||||
dmrSynthBars[i].targetHeight = Math.max(2,
|
||||
(baseHeight + wave1 + wave2 + random + rippleBoost + voiceRipple) *
|
||||
effectiveActivity * centerBoost * height
|
||||
);
|
||||
|
||||
// Spring physics
|
||||
const springStrength = effectiveActivity > 0.3 ? 0.15 : 0.1;
|
||||
const diff = dmrSynthBars[i].targetHeight - dmrSynthBars[i].height;
|
||||
dmrSynthBars[i].velocity += diff * springStrength;
|
||||
dmrSynthBars[i].velocity *= 0.78;
|
||||
dmrSynthBars[i].height += dmrSynthBars[i].velocity;
|
||||
dmrSynthBars[i].height = Math.max(2, Math.min(height - 4, dmrSynthBars[i].height));
|
||||
}
|
||||
|
||||
// Draw bars
|
||||
for (let i = 0; i < DMR_BAR_COUNT; i++) {
|
||||
const x = i * (barWidth + 2) + 1;
|
||||
const barHeight = dmrSynthBars[i].height;
|
||||
const y = (height - barHeight) / 2;
|
||||
|
||||
// HSL color by event type
|
||||
let hue, saturation, lightness;
|
||||
if (dmrEventType === 'voice' && timeSinceEvent < 3000) {
|
||||
hue = 30; // Orange
|
||||
saturation = 85;
|
||||
lightness = 40 + (barHeight / height) * 25;
|
||||
} else if (dmrEventType === 'call' && timeSinceEvent < 3000) {
|
||||
hue = 120; // Green
|
||||
saturation = 80;
|
||||
lightness = 35 + (barHeight / height) * 30;
|
||||
} else if (dmrEventType === 'sync' && timeSinceEvent < 2000) {
|
||||
hue = 185; // Cyan
|
||||
saturation = 85;
|
||||
lightness = 38 + (barHeight / height) * 25;
|
||||
} else if (dmrEventType === 'stopped') {
|
||||
hue = 220;
|
||||
saturation = 20;
|
||||
lightness = 18 + (barHeight / height) * 8;
|
||||
} else {
|
||||
// Idle / decayed
|
||||
hue = 210;
|
||||
saturation = 40;
|
||||
lightness = 25 + (barHeight / height) * 15;
|
||||
}
|
||||
|
||||
// Vertical gradient per bar
|
||||
const gradient = dmrSynthCtx.createLinearGradient(x, y, x, y + barHeight);
|
||||
gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`);
|
||||
gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`);
|
||||
gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`);
|
||||
|
||||
dmrSynthCtx.fillStyle = gradient;
|
||||
dmrSynthCtx.fillRect(x, y, barWidth, barHeight);
|
||||
|
||||
// Glow on tall bars
|
||||
if (barHeight > height * 0.5 && effectiveActivity > 0.4) {
|
||||
dmrSynthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`;
|
||||
dmrSynthCtx.shadowBlur = 8;
|
||||
dmrSynthCtx.fillRect(x, y, barWidth, barHeight);
|
||||
dmrSynthCtx.shadowBlur = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Center line
|
||||
dmrSynthCtx.strokeStyle = 'rgba(0, 212, 255, 0.15)';
|
||||
dmrSynthCtx.lineWidth = 1;
|
||||
dmrSynthCtx.beginPath();
|
||||
dmrSynthCtx.moveTo(0, height / 2);
|
||||
dmrSynthCtx.lineTo(width, height / 2);
|
||||
dmrSynthCtx.stroke();
|
||||
|
||||
dmrSynthAnimationId = requestAnimationFrame(drawDmrSynthesizer);
|
||||
}
|
||||
|
||||
function dmrSynthPulse(type) {
|
||||
dmrLastEventTime = Date.now();
|
||||
|
||||
if (type === 'sync') {
|
||||
dmrActivityTarget = Math.max(dmrActivityTarget, DMR_BURST_SYNC);
|
||||
dmrEventType = 'sync';
|
||||
} else if (type === 'call') {
|
||||
dmrActivityTarget = DMR_BURST_CALL;
|
||||
dmrEventType = 'call';
|
||||
} else if (type === 'voice') {
|
||||
dmrActivityTarget = DMR_BURST_VOICE;
|
||||
dmrEventType = 'voice';
|
||||
} else if (type === 'slot' || type === 'nac') {
|
||||
dmrActivityTarget = Math.max(dmrActivityTarget, 0.5);
|
||||
} else if (type === 'raw') {
|
||||
// Any DSD output means the decoder is alive and processing
|
||||
dmrActivityTarget = Math.max(dmrActivityTarget, 0.25);
|
||||
if (dmrEventType === 'idle') dmrEventType = 'raw';
|
||||
}
|
||||
// keepalive and status don't change visuals
|
||||
|
||||
updateDmrSynthStatus();
|
||||
}
|
||||
|
||||
function updateDmrSynthStatus() {
|
||||
const el = document.getElementById('dmrSynthStatus');
|
||||
if (!el) return;
|
||||
|
||||
const labels = {
|
||||
stopped: 'STOPPED',
|
||||
idle: 'IDLE',
|
||||
raw: 'LISTENING',
|
||||
sync: 'SYNC',
|
||||
call: 'CALL',
|
||||
voice: 'VOICE'
|
||||
};
|
||||
const colors = {
|
||||
stopped: 'var(--text-muted)',
|
||||
idle: 'var(--text-muted)',
|
||||
raw: '#607d8b',
|
||||
sync: '#00e5ff',
|
||||
call: '#4caf50',
|
||||
voice: '#ff9800'
|
||||
};
|
||||
|
||||
el.textContent = labels[dmrEventType] || 'IDLE';
|
||||
el.style.color = colors[dmrEventType] || 'var(--text-muted)';
|
||||
}
|
||||
|
||||
function resizeDmrSynthesizer() {
|
||||
if (!dmrSynthCanvas) return;
|
||||
const rect = dmrSynthCanvas.getBoundingClientRect();
|
||||
if (rect.width > 0) {
|
||||
dmrSynthCanvas.width = Math.round(rect.width);
|
||||
dmrSynthCanvas.height = Math.round(rect.height) || 70;
|
||||
}
|
||||
}
|
||||
|
||||
function stopDmrSynthesizer() {
|
||||
if (dmrSynthAnimationId) {
|
||||
cancelAnimationFrame(dmrSynthAnimationId);
|
||||
dmrSynthAnimationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('resize', resizeDmrSynthesizer);
|
||||
|
||||
// ============== EXPORTS ==============
|
||||
|
||||
window.startDmr = startDmr;
|
||||
window.stopDmr = stopDmr;
|
||||
window.checkDmrTools = checkDmrTools;
|
||||
window.initDmrSynthesizer = initDmrSynthesizer;
|
||||
@@ -319,7 +319,7 @@ function stopScanner() {
|
||||
? `/controller/agents/${listeningPostCurrentAgent}/listening_post/stop`
|
||||
: '/listening/scanner/stop';
|
||||
|
||||
fetch(endpoint, { method: 'POST' })
|
||||
return fetch(endpoint, { method: 'POST' })
|
||||
.then(() => {
|
||||
if (!isAgentMode && typeof releaseDevice === 'function') releaseDevice('scanner');
|
||||
listeningPostCurrentAgent = null;
|
||||
@@ -830,6 +830,11 @@ function handleSignalFound(data) {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Signal Found!', `${freqStr} MHz - Audio streaming`);
|
||||
}
|
||||
|
||||
// Auto-trigger signal identification
|
||||
if (typeof guessSignal === 'function') {
|
||||
guessSignal(data.frequency, data.modulation);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSignalLost(data) {
|
||||
@@ -2240,9 +2245,14 @@ async function _startDirectListenInternal() {
|
||||
|
||||
try {
|
||||
if (isScannerRunning) {
|
||||
stopScanner();
|
||||
await stopScanner();
|
||||
}
|
||||
|
||||
if (isWaterfallRunning && waterfallMode === 'rf') {
|
||||
resumeRfWaterfallAfterListening = true;
|
||||
await stopWaterfall();
|
||||
}
|
||||
|
||||
const freqInput = document.getElementById('radioScanStart');
|
||||
const freq = freqInput ? parseFloat(freqInput.value) : 118.0;
|
||||
const squelchValue = parseInt(document.getElementById('radioSquelchValue')?.textContent);
|
||||
@@ -2301,6 +2311,10 @@ async function _startDirectListenInternal() {
|
||||
addScannerLogEntry('Failed: ' + (result.message || 'Unknown error'), '', 'error');
|
||||
isDirectListening = false;
|
||||
updateDirectListenUI(false);
|
||||
if (resumeRfWaterfallAfterListening) {
|
||||
resumeRfWaterfallAfterListening = false;
|
||||
setTimeout(() => startWaterfall(), 200);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2347,6 +2361,15 @@ async function _startDirectListenInternal() {
|
||||
initAudioVisualizer();
|
||||
|
||||
isDirectListening = true;
|
||||
|
||||
if (resumeRfWaterfallAfterListening) {
|
||||
isWaterfallRunning = true;
|
||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
||||
document.getElementById('startWaterfallBtn').style.display = 'none';
|
||||
document.getElementById('stopWaterfallBtn').style.display = 'block';
|
||||
startAudioWaterfall();
|
||||
}
|
||||
updateDirectListenUI(true, freq);
|
||||
addScannerLogEntry(`${freq.toFixed(3)} MHz (${currentModulation.toUpperCase()})`, '', 'signal');
|
||||
|
||||
@@ -2355,6 +2378,10 @@ async function _startDirectListenInternal() {
|
||||
addScannerLogEntry('Error: ' + e.message, '', 'error');
|
||||
isDirectListening = false;
|
||||
updateDirectListenUI(false);
|
||||
if (resumeRfWaterfallAfterListening) {
|
||||
resumeRfWaterfallAfterListening = false;
|
||||
setTimeout(() => startWaterfall(), 200);
|
||||
}
|
||||
} finally {
|
||||
isRestarting = false;
|
||||
}
|
||||
@@ -2551,6 +2578,20 @@ function stopDirectListen() {
|
||||
currentSignalLevel = 0;
|
||||
updateDirectListenUI(false);
|
||||
addScannerLogEntry('Listening stopped');
|
||||
|
||||
if (waterfallMode === 'audio') {
|
||||
stopAudioWaterfall();
|
||||
}
|
||||
|
||||
if (resumeRfWaterfallAfterListening) {
|
||||
resumeRfWaterfallAfterListening = false;
|
||||
isWaterfallRunning = false;
|
||||
setTimeout(() => startWaterfall(), 200);
|
||||
} else if (waterfallMode === 'audio' && isWaterfallRunning) {
|
||||
isWaterfallRunning = false;
|
||||
document.getElementById('startWaterfallBtn').style.display = 'block';
|
||||
document.getElementById('stopWaterfallBtn').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2937,6 +2978,505 @@ window.updateListenButtonState = updateListenButtonState;
|
||||
// Export functions for HTML onclick handlers
|
||||
window.toggleDirectListen = toggleDirectListen;
|
||||
window.startDirectListen = startDirectListen;
|
||||
// ============== SIGNAL IDENTIFICATION ==============
|
||||
|
||||
function guessSignal(frequencyMhz, modulation) {
|
||||
const body = { frequency_mhz: frequencyMhz };
|
||||
if (modulation) body.modulation = modulation;
|
||||
|
||||
return fetch('/listening/signal/guess', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'ok') {
|
||||
renderSignalGuess(data);
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.catch(err => console.error('[SIGNAL-ID] Error:', err));
|
||||
}
|
||||
|
||||
function renderSignalGuess(result) {
|
||||
const panel = document.getElementById('signalGuessPanel');
|
||||
if (!panel) return;
|
||||
panel.style.display = 'block';
|
||||
|
||||
const label = document.getElementById('signalGuessLabel');
|
||||
const badge = document.getElementById('signalGuessBadge');
|
||||
const explanation = document.getElementById('signalGuessExplanation');
|
||||
const tagsEl = document.getElementById('signalGuessTags');
|
||||
const altsEl = document.getElementById('signalGuessAlternatives');
|
||||
|
||||
if (label) label.textContent = result.primary_label || 'Unknown';
|
||||
|
||||
if (badge) {
|
||||
badge.textContent = result.confidence || '';
|
||||
const colors = { 'HIGH': '#00e676', 'MEDIUM': '#ff9800', 'LOW': '#9e9e9e' };
|
||||
badge.style.background = colors[result.confidence] || '#9e9e9e';
|
||||
badge.style.color = '#000';
|
||||
}
|
||||
|
||||
if (explanation) explanation.textContent = result.explanation || '';
|
||||
|
||||
if (tagsEl) {
|
||||
tagsEl.innerHTML = (result.tags || []).map(tag =>
|
||||
`<span style="background: rgba(0,200,255,0.15); color: var(--accent-cyan); padding: 1px 6px; border-radius: 3px; font-size: 9px;">${tag}</span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
if (altsEl) {
|
||||
if (result.alternatives && result.alternatives.length > 0) {
|
||||
altsEl.innerHTML = '<strong>Also:</strong> ' + result.alternatives.map(a =>
|
||||
`${a.label} <span style="color: ${a.confidence === 'HIGH' ? '#00e676' : a.confidence === 'MEDIUM' ? '#ff9800' : '#9e9e9e'}">(${a.confidence})</span>`
|
||||
).join(', ');
|
||||
} else {
|
||||
altsEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function manualSignalGuess() {
|
||||
const input = document.getElementById('signalGuessFreqInput');
|
||||
if (!input || !input.value) return;
|
||||
const freq = parseFloat(input.value);
|
||||
if (isNaN(freq) || freq <= 0) return;
|
||||
guessSignal(freq, currentModulation);
|
||||
}
|
||||
|
||||
|
||||
// ============== WATERFALL / SPECTROGRAM ==============
|
||||
|
||||
let isWaterfallRunning = false;
|
||||
let waterfallEventSource = null;
|
||||
let waterfallCanvas = null;
|
||||
let waterfallCtx = null;
|
||||
let spectrumCanvas = null;
|
||||
let spectrumCtx = null;
|
||||
let waterfallStartFreq = 88;
|
||||
let waterfallEndFreq = 108;
|
||||
let waterfallRowImage = null;
|
||||
let waterfallPalette = null;
|
||||
let lastWaterfallDraw = 0;
|
||||
const WATERFALL_MIN_INTERVAL_MS = 50;
|
||||
let waterfallInteractionBound = false;
|
||||
let waterfallResizeObserver = null;
|
||||
let waterfallMode = 'rf';
|
||||
let audioWaterfallAnimId = null;
|
||||
let lastAudioWaterfallDraw = 0;
|
||||
let resumeRfWaterfallAfterListening = false;
|
||||
|
||||
function resizeCanvasToDisplaySize(canvas) {
|
||||
if (!canvas) return false;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return false;
|
||||
const width = Math.max(1, Math.round(rect.width * dpr));
|
||||
const height = Math.max(1, Math.round(rect.height * dpr));
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getWaterfallRowHeight() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
return Math.max(1, Math.round(dpr));
|
||||
}
|
||||
|
||||
function initWaterfallCanvas() {
|
||||
waterfallCanvas = document.getElementById('waterfallCanvas');
|
||||
spectrumCanvas = document.getElementById('spectrumCanvas');
|
||||
if (waterfallCanvas) {
|
||||
resizeCanvasToDisplaySize(waterfallCanvas);
|
||||
waterfallCtx = waterfallCanvas.getContext('2d');
|
||||
if (waterfallCtx) {
|
||||
waterfallCtx.imageSmoothingEnabled = false;
|
||||
waterfallRowImage = waterfallCtx.createImageData(
|
||||
waterfallCanvas.width,
|
||||
getWaterfallRowHeight()
|
||||
);
|
||||
}
|
||||
}
|
||||
if (spectrumCanvas) {
|
||||
resizeCanvasToDisplaySize(spectrumCanvas);
|
||||
spectrumCtx = spectrumCanvas.getContext('2d');
|
||||
if (spectrumCtx) {
|
||||
spectrumCtx.imageSmoothingEnabled = false;
|
||||
}
|
||||
}
|
||||
if (!waterfallPalette) waterfallPalette = buildWaterfallPalette();
|
||||
|
||||
if (!waterfallInteractionBound) {
|
||||
bindWaterfallInteraction();
|
||||
waterfallInteractionBound = true;
|
||||
}
|
||||
|
||||
if (!waterfallResizeObserver && waterfallCanvas) {
|
||||
const observerTarget = waterfallCanvas.parentElement;
|
||||
if (observerTarget && typeof ResizeObserver !== 'undefined') {
|
||||
waterfallResizeObserver = new ResizeObserver(() => {
|
||||
const resizedWaterfall = resizeCanvasToDisplaySize(waterfallCanvas);
|
||||
const resizedSpectrum = spectrumCanvas ? resizeCanvasToDisplaySize(spectrumCanvas) : false;
|
||||
if (resizedWaterfall && waterfallCtx) {
|
||||
waterfallRowImage = waterfallCtx.createImageData(
|
||||
waterfallCanvas.width,
|
||||
getWaterfallRowHeight()
|
||||
);
|
||||
}
|
||||
if (resizedWaterfall || resizedSpectrum) {
|
||||
lastWaterfallDraw = 0;
|
||||
}
|
||||
});
|
||||
waterfallResizeObserver.observe(observerTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setWaterfallMode(mode) {
|
||||
waterfallMode = mode;
|
||||
const header = document.getElementById('waterfallFreqRange');
|
||||
if (!header) return;
|
||||
if (mode === 'audio') {
|
||||
header.textContent = 'Audio Spectrum (0 - 22 kHz)';
|
||||
}
|
||||
}
|
||||
|
||||
function startAudioWaterfall() {
|
||||
if (audioWaterfallAnimId) return;
|
||||
if (!visualizerAnalyser) {
|
||||
initAudioVisualizer();
|
||||
}
|
||||
if (!visualizerAnalyser) return;
|
||||
|
||||
setWaterfallMode('audio');
|
||||
initWaterfallCanvas();
|
||||
|
||||
const sampleRate = visualizerContext ? visualizerContext.sampleRate : 44100;
|
||||
const maxFreqKhz = (sampleRate / 2) / 1000;
|
||||
const dataArray = new Uint8Array(visualizerAnalyser.frequencyBinCount);
|
||||
|
||||
const drawFrame = (ts) => {
|
||||
if (!isDirectListening || waterfallMode !== 'audio') {
|
||||
stopAudioWaterfall();
|
||||
return;
|
||||
}
|
||||
if (ts - lastAudioWaterfallDraw >= WATERFALL_MIN_INTERVAL_MS) {
|
||||
lastAudioWaterfallDraw = ts;
|
||||
visualizerAnalyser.getByteFrequencyData(dataArray);
|
||||
const bins = Array.from(dataArray, v => v);
|
||||
drawWaterfallRow(bins);
|
||||
drawSpectrumLine(bins, 0, maxFreqKhz, 'kHz');
|
||||
}
|
||||
audioWaterfallAnimId = requestAnimationFrame(drawFrame);
|
||||
};
|
||||
|
||||
audioWaterfallAnimId = requestAnimationFrame(drawFrame);
|
||||
}
|
||||
|
||||
function stopAudioWaterfall() {
|
||||
if (audioWaterfallAnimId) {
|
||||
cancelAnimationFrame(audioWaterfallAnimId);
|
||||
audioWaterfallAnimId = null;
|
||||
}
|
||||
if (waterfallMode === 'audio') {
|
||||
waterfallMode = 'rf';
|
||||
}
|
||||
}
|
||||
|
||||
function dBmToRgb(normalized) {
|
||||
// Viridis-inspired: dark blue -> cyan -> green -> yellow
|
||||
const n = Math.max(0, Math.min(1, normalized));
|
||||
let r, g, b;
|
||||
if (n < 0.25) {
|
||||
const t = n / 0.25;
|
||||
r = Math.round(20 + t * 20);
|
||||
g = Math.round(10 + t * 60);
|
||||
b = Math.round(80 + t * 100);
|
||||
} else if (n < 0.5) {
|
||||
const t = (n - 0.25) / 0.25;
|
||||
r = Math.round(40 - t * 20);
|
||||
g = Math.round(70 + t * 130);
|
||||
b = Math.round(180 - t * 30);
|
||||
} else if (n < 0.75) {
|
||||
const t = (n - 0.5) / 0.25;
|
||||
r = Math.round(20 + t * 180);
|
||||
g = Math.round(200 + t * 55);
|
||||
b = Math.round(150 - t * 130);
|
||||
} else {
|
||||
const t = (n - 0.75) / 0.25;
|
||||
r = Math.round(200 + t * 55);
|
||||
g = Math.round(255 - t * 55);
|
||||
b = Math.round(20 - t * 20);
|
||||
}
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
function buildWaterfallPalette() {
|
||||
const palette = new Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
palette[i] = dBmToRgb(i / 255);
|
||||
}
|
||||
return palette;
|
||||
}
|
||||
|
||||
function drawWaterfallRow(bins) {
|
||||
if (!waterfallCtx || !waterfallCanvas) return;
|
||||
const w = waterfallCanvas.width;
|
||||
const h = waterfallCanvas.height;
|
||||
const rowHeight = waterfallRowImage ? waterfallRowImage.height : 1;
|
||||
|
||||
// Scroll existing content down by 1 pixel (GPU-accelerated)
|
||||
waterfallCtx.drawImage(waterfallCanvas, 0, 0, w, h - rowHeight, 0, rowHeight, w, h - rowHeight);
|
||||
|
||||
// Find min/max for normalization
|
||||
let minVal = Infinity, maxVal = -Infinity;
|
||||
for (let i = 0; i < bins.length; i++) {
|
||||
if (bins[i] < minVal) minVal = bins[i];
|
||||
if (bins[i] > maxVal) maxVal = bins[i];
|
||||
}
|
||||
const range = maxVal - minVal || 1;
|
||||
|
||||
// Draw new row at top using ImageData
|
||||
if (!waterfallRowImage || waterfallRowImage.width !== w || waterfallRowImage.height !== rowHeight) {
|
||||
waterfallRowImage = waterfallCtx.createImageData(w, rowHeight);
|
||||
}
|
||||
const rowData = waterfallRowImage.data;
|
||||
const palette = waterfallPalette || buildWaterfallPalette();
|
||||
const binCount = bins.length;
|
||||
for (let x = 0; x < w; x++) {
|
||||
const pos = (x / (w - 1)) * (binCount - 1);
|
||||
const i0 = Math.floor(pos);
|
||||
const i1 = Math.min(binCount - 1, i0 + 1);
|
||||
const t = pos - i0;
|
||||
const val = (bins[i0] * (1 - t)) + (bins[i1] * t);
|
||||
const normalized = (val - minVal) / range;
|
||||
const color = palette[Math.max(0, Math.min(255, Math.floor(normalized * 255)))] || [0, 0, 0];
|
||||
for (let y = 0; y < rowHeight; y++) {
|
||||
const offset = (y * w + x) * 4;
|
||||
rowData[offset] = color[0];
|
||||
rowData[offset + 1] = color[1];
|
||||
rowData[offset + 2] = color[2];
|
||||
rowData[offset + 3] = 255;
|
||||
}
|
||||
}
|
||||
waterfallCtx.putImageData(waterfallRowImage, 0, 0);
|
||||
}
|
||||
|
||||
function drawSpectrumLine(bins, startFreq, endFreq, labelUnit) {
|
||||
if (!spectrumCtx || !spectrumCanvas) return;
|
||||
const w = spectrumCanvas.width;
|
||||
const h = spectrumCanvas.height;
|
||||
|
||||
spectrumCtx.clearRect(0, 0, w, h);
|
||||
|
||||
// Background
|
||||
spectrumCtx.fillStyle = 'rgba(0, 0, 0, 0.8)';
|
||||
spectrumCtx.fillRect(0, 0, w, h);
|
||||
|
||||
// Grid lines
|
||||
spectrumCtx.strokeStyle = 'rgba(0, 200, 255, 0.1)';
|
||||
spectrumCtx.lineWidth = 0.5;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const y = (h / 5) * i;
|
||||
spectrumCtx.beginPath();
|
||||
spectrumCtx.moveTo(0, y);
|
||||
spectrumCtx.lineTo(w, y);
|
||||
spectrumCtx.stroke();
|
||||
}
|
||||
|
||||
// Frequency labels
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
spectrumCtx.fillStyle = 'rgba(0, 200, 255, 0.5)';
|
||||
spectrumCtx.font = `${9 * dpr}px monospace`;
|
||||
const freqRange = endFreq - startFreq;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const freq = startFreq + (freqRange / 4) * i;
|
||||
const x = (w / 4) * i;
|
||||
const label = labelUnit === 'kHz' ? freq.toFixed(0) : freq.toFixed(1);
|
||||
spectrumCtx.fillText(label, x + 2, h - 2);
|
||||
}
|
||||
|
||||
if (bins.length === 0) return;
|
||||
|
||||
// Find min/max for scaling
|
||||
let minVal = Infinity, maxVal = -Infinity;
|
||||
for (let i = 0; i < bins.length; i++) {
|
||||
if (bins[i] < minVal) minVal = bins[i];
|
||||
if (bins[i] > maxVal) maxVal = bins[i];
|
||||
}
|
||||
const range = maxVal - minVal || 1;
|
||||
|
||||
// Draw spectrum line
|
||||
spectrumCtx.strokeStyle = 'rgba(0, 255, 255, 0.9)';
|
||||
spectrumCtx.lineWidth = 1.5;
|
||||
spectrumCtx.beginPath();
|
||||
for (let i = 0; i < bins.length; i++) {
|
||||
const x = (i / (bins.length - 1)) * w;
|
||||
const normalized = (bins[i] - minVal) / range;
|
||||
const y = h - 12 - normalized * (h - 16);
|
||||
if (i === 0) spectrumCtx.moveTo(x, y);
|
||||
else spectrumCtx.lineTo(x, y);
|
||||
}
|
||||
spectrumCtx.stroke();
|
||||
|
||||
// Fill under line
|
||||
const lastX = w;
|
||||
const lastY = h - 12 - ((bins[bins.length - 1] - minVal) / range) * (h - 16);
|
||||
spectrumCtx.lineTo(lastX, h);
|
||||
spectrumCtx.lineTo(0, h);
|
||||
spectrumCtx.closePath();
|
||||
spectrumCtx.fillStyle = 'rgba(0, 255, 255, 0.08)';
|
||||
spectrumCtx.fill();
|
||||
}
|
||||
|
||||
function startWaterfall() {
|
||||
const startFreq = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88);
|
||||
const endFreq = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108);
|
||||
const binSize = parseInt(document.getElementById('waterfallBinSize')?.value || 10000);
|
||||
const gain = parseInt(document.getElementById('waterfallGain')?.value || 40);
|
||||
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
||||
initWaterfallCanvas();
|
||||
const maxBins = Math.min(4096, Math.max(128, waterfallCanvas ? waterfallCanvas.width : 800));
|
||||
|
||||
if (startFreq >= endFreq) {
|
||||
if (typeof showNotification === 'function') showNotification('Error', 'End frequency must be greater than start');
|
||||
return;
|
||||
}
|
||||
|
||||
waterfallStartFreq = startFreq;
|
||||
waterfallEndFreq = endFreq;
|
||||
const rangeLabel = document.getElementById('waterfallFreqRange');
|
||||
if (rangeLabel) {
|
||||
rangeLabel.textContent = `${startFreq.toFixed(1)} - ${endFreq.toFixed(1)} MHz`;
|
||||
}
|
||||
|
||||
if (isDirectListening) {
|
||||
isWaterfallRunning = true;
|
||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
||||
document.getElementById('startWaterfallBtn').style.display = 'none';
|
||||
document.getElementById('stopWaterfallBtn').style.display = 'block';
|
||||
startAudioWaterfall();
|
||||
return;
|
||||
}
|
||||
|
||||
setWaterfallMode('rf');
|
||||
const spanMhz = Math.max(0.1, waterfallEndFreq - waterfallStartFreq);
|
||||
const segments = Math.max(1, Math.ceil(spanMhz / 2.4));
|
||||
const targetSweepSeconds = 0.8;
|
||||
const interval = Math.max(0.1, Math.min(0.3, targetSweepSeconds / segments));
|
||||
|
||||
fetch('/listening/waterfall/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
start_freq: startFreq,
|
||||
end_freq: endFreq,
|
||||
bin_size: binSize,
|
||||
gain: gain,
|
||||
device: device,
|
||||
max_bins: maxBins,
|
||||
interval: interval,
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'started') {
|
||||
isWaterfallRunning = true;
|
||||
document.getElementById('startWaterfallBtn').style.display = 'none';
|
||||
document.getElementById('stopWaterfallBtn').style.display = 'block';
|
||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
||||
lastWaterfallDraw = 0;
|
||||
initWaterfallCanvas();
|
||||
connectWaterfallSSE();
|
||||
} else {
|
||||
if (typeof showNotification === 'function') showNotification('Error', data.message || 'Failed to start waterfall');
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[WATERFALL] Start error:', err));
|
||||
}
|
||||
|
||||
async function stopWaterfall() {
|
||||
if (waterfallMode === 'audio') {
|
||||
stopAudioWaterfall();
|
||||
isWaterfallRunning = false;
|
||||
document.getElementById('startWaterfallBtn').style.display = 'block';
|
||||
document.getElementById('stopWaterfallBtn').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch('/listening/waterfall/stop', { method: 'POST' });
|
||||
isWaterfallRunning = false;
|
||||
if (waterfallEventSource) { waterfallEventSource.close(); waterfallEventSource = null; }
|
||||
document.getElementById('startWaterfallBtn').style.display = 'block';
|
||||
document.getElementById('stopWaterfallBtn').style.display = 'none';
|
||||
} catch (err) {
|
||||
console.error('[WATERFALL] Stop error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function connectWaterfallSSE() {
|
||||
if (waterfallEventSource) waterfallEventSource.close();
|
||||
waterfallEventSource = new EventSource('/listening/waterfall/stream');
|
||||
waterfallMode = 'rf';
|
||||
|
||||
waterfallEventSource.onmessage = function(event) {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'waterfall_sweep') {
|
||||
if (typeof msg.start_freq === 'number') waterfallStartFreq = msg.start_freq;
|
||||
if (typeof msg.end_freq === 'number') waterfallEndFreq = msg.end_freq;
|
||||
const rangeLabel = document.getElementById('waterfallFreqRange');
|
||||
if (rangeLabel) {
|
||||
rangeLabel.textContent = `${waterfallStartFreq.toFixed(1)} - ${waterfallEndFreq.toFixed(1)} MHz`;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - lastWaterfallDraw < WATERFALL_MIN_INTERVAL_MS) return;
|
||||
lastWaterfallDraw = now;
|
||||
drawWaterfallRow(msg.bins);
|
||||
drawSpectrumLine(msg.bins, msg.start_freq, msg.end_freq);
|
||||
}
|
||||
};
|
||||
|
||||
waterfallEventSource.onerror = function() {
|
||||
if (isWaterfallRunning) {
|
||||
setTimeout(connectWaterfallSSE, 2000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function bindWaterfallInteraction() {
|
||||
const handler = (event) => {
|
||||
if (waterfallMode === 'audio') {
|
||||
return;
|
||||
}
|
||||
const canvas = event.currentTarget;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const ratio = Math.max(0, Math.min(1, x / rect.width));
|
||||
const freq = waterfallStartFreq + ratio * (waterfallEndFreq - waterfallStartFreq);
|
||||
if (typeof tuneToFrequency === 'function') {
|
||||
tuneToFrequency(freq, typeof currentModulation !== 'undefined' ? currentModulation : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
if (waterfallCanvas) {
|
||||
waterfallCanvas.style.cursor = 'crosshair';
|
||||
waterfallCanvas.addEventListener('click', handler);
|
||||
}
|
||||
if (spectrumCanvas) {
|
||||
spectrumCanvas.style.cursor = 'crosshair';
|
||||
spectrumCanvas.addEventListener('click', handler);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
window.stopDirectListen = stopDirectListen;
|
||||
window.toggleScanner = toggleScanner;
|
||||
window.startScanner = startScanner;
|
||||
@@ -2953,3 +3493,7 @@ window.removeBookmark = removeBookmark;
|
||||
window.tuneToFrequency = tuneToFrequency;
|
||||
window.clearScannerLog = clearScannerLog;
|
||||
window.exportScannerLog = exportScannerLog;
|
||||
window.manualSignalGuess = manualSignalGuess;
|
||||
window.guessSignal = guessSignal;
|
||||
window.startWaterfall = startWaterfall;
|
||||
window.stopWaterfall = stopWaterfall;
|
||||
|
||||
601
static/js/modes/sstv-general.js
Normal file
601
static/js/modes/sstv-general.js
Normal file
@@ -0,0 +1,601 @@
|
||||
/**
|
||||
* SSTV General Mode
|
||||
* Terrestrial Slow-Scan Television decoder interface
|
||||
*/
|
||||
|
||||
const SSTVGeneral = (function() {
|
||||
// State
|
||||
let isRunning = false;
|
||||
let eventSource = null;
|
||||
let images = [];
|
||||
let currentMode = null;
|
||||
let progress = 0;
|
||||
|
||||
/**
|
||||
* Initialize the SSTV General mode
|
||||
*/
|
||||
function init() {
|
||||
checkStatus();
|
||||
loadImages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a preset frequency from the dropdown
|
||||
*/
|
||||
function selectPreset(value) {
|
||||
if (!value) return;
|
||||
|
||||
const parts = value.split('|');
|
||||
const freq = parseFloat(parts[0]);
|
||||
const mod = parts[1];
|
||||
|
||||
const freqInput = document.getElementById('sstvGeneralFrequency');
|
||||
const modSelect = document.getElementById('sstvGeneralModulation');
|
||||
|
||||
if (freqInput) freqInput.value = freq;
|
||||
if (modSelect) modSelect.value = mod;
|
||||
|
||||
// Update strip display
|
||||
const stripFreq = document.getElementById('sstvGeneralStripFreq');
|
||||
const stripMod = document.getElementById('sstvGeneralStripMod');
|
||||
if (stripFreq) stripFreq.textContent = freq.toFixed(3);
|
||||
if (stripMod) stripMod.textContent = mod.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check current decoder status
|
||||
*/
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const response = await fetch('/sstv-general/status');
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.available) {
|
||||
updateStatusUI('unavailable', 'Decoder not installed');
|
||||
showStatusMessage('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.running) {
|
||||
isRunning = true;
|
||||
updateStatusUI('listening', 'Listening...');
|
||||
startStream();
|
||||
} else {
|
||||
updateStatusUI('idle', 'Idle');
|
||||
}
|
||||
|
||||
updateImageCount(data.image_count || 0);
|
||||
} catch (err) {
|
||||
console.error('Failed to check SSTV General status:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start SSTV decoder
|
||||
*/
|
||||
async function start() {
|
||||
const freqInput = document.getElementById('sstvGeneralFrequency');
|
||||
const modSelect = document.getElementById('sstvGeneralModulation');
|
||||
const deviceSelect = document.getElementById('deviceSelect');
|
||||
|
||||
const frequency = parseFloat(freqInput?.value || '14.230');
|
||||
const modulation = modSelect?.value || 'usb';
|
||||
const device = parseInt(deviceSelect?.value || '0', 10);
|
||||
|
||||
updateStatusUI('connecting', 'Starting...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/sstv-general/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ frequency, modulation, device })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'started' || data.status === 'already_running') {
|
||||
isRunning = true;
|
||||
updateStatusUI('listening', `${frequency} MHz ${modulation.toUpperCase()}`);
|
||||
startStream();
|
||||
showNotification('SSTV', `Listening on ${frequency} MHz ${modulation.toUpperCase()}`);
|
||||
|
||||
// Update strip
|
||||
const stripFreq = document.getElementById('sstvGeneralStripFreq');
|
||||
const stripMod = document.getElementById('sstvGeneralStripMod');
|
||||
if (stripFreq) stripFreq.textContent = frequency.toFixed(3);
|
||||
if (stripMod) stripMod.textContent = modulation.toUpperCase();
|
||||
} else {
|
||||
updateStatusUI('idle', 'Start failed');
|
||||
showStatusMessage(data.message || 'Failed to start decoder', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to start SSTV General:', err);
|
||||
updateStatusUI('idle', 'Error');
|
||||
showStatusMessage('Connection error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop SSTV decoder
|
||||
*/
|
||||
async function stop() {
|
||||
try {
|
||||
await fetch('/sstv-general/stop', { method: 'POST' });
|
||||
isRunning = false;
|
||||
stopStream();
|
||||
updateStatusUI('idle', 'Stopped');
|
||||
showNotification('SSTV', 'Decoder stopped');
|
||||
} catch (err) {
|
||||
console.error('Failed to stop SSTV General:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status UI elements
|
||||
*/
|
||||
function updateStatusUI(status, text) {
|
||||
const dot = document.getElementById('sstvGeneralStripDot');
|
||||
const statusText = document.getElementById('sstvGeneralStripStatus');
|
||||
const startBtn = document.getElementById('sstvGeneralStartBtn');
|
||||
const stopBtn = document.getElementById('sstvGeneralStopBtn');
|
||||
|
||||
if (dot) {
|
||||
dot.className = 'sstv-general-strip-dot';
|
||||
if (status === 'listening' || status === 'detecting') {
|
||||
dot.classList.add('listening');
|
||||
} else if (status === 'decoding') {
|
||||
dot.classList.add('decoding');
|
||||
} else {
|
||||
dot.classList.add('idle');
|
||||
}
|
||||
}
|
||||
|
||||
if (statusText) {
|
||||
statusText.textContent = text || status;
|
||||
}
|
||||
|
||||
if (startBtn && stopBtn) {
|
||||
if (status === 'listening' || status === 'decoding') {
|
||||
startBtn.style.display = 'none';
|
||||
stopBtn.style.display = 'inline-block';
|
||||
} else {
|
||||
startBtn.style.display = 'inline-block';
|
||||
stopBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Update live content area
|
||||
const liveContent = document.getElementById('sstvGeneralLiveContent');
|
||||
if (liveContent) {
|
||||
if (status === 'idle' || status === 'unavailable') {
|
||||
liveContent.innerHTML = renderIdleState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render idle state HTML
|
||||
*/
|
||||
function renderIdleState() {
|
||||
return `
|
||||
<div class="sstv-general-idle-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M3 9h2M19 9h2M3 15h2M19 15h2"/>
|
||||
</svg>
|
||||
<h4>SSTV Decoder</h4>
|
||||
<p>Select a frequency and click Start to listen for SSTV transmissions</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start SSE stream
|
||||
*/
|
||||
function startStream() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
eventSource = new EventSource('/sstv-general/stream');
|
||||
|
||||
eventSource.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'sstv_progress') {
|
||||
handleProgress(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
console.warn('SSTV General SSE error, will reconnect...');
|
||||
setTimeout(() => {
|
||||
if (isRunning) startStream();
|
||||
}, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop SSE stream
|
||||
*/
|
||||
function stopStream() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle progress update
|
||||
*/
|
||||
function handleProgress(data) {
|
||||
currentMode = data.mode || currentMode;
|
||||
progress = data.progress || 0;
|
||||
|
||||
if (data.status === 'decoding') {
|
||||
updateStatusUI('decoding', `Decoding ${currentMode || 'image'}...`);
|
||||
renderDecodeProgress(data);
|
||||
} else if (data.status === 'complete' && data.image) {
|
||||
images.unshift(data.image);
|
||||
updateImageCount(images.length);
|
||||
renderGallery();
|
||||
showNotification('SSTV', 'New image decoded!');
|
||||
updateStatusUI('listening', 'Listening...');
|
||||
// Clear decode progress so signal monitor can take over
|
||||
const liveContent = document.getElementById('sstvGeneralLiveContent');
|
||||
if (liveContent) liveContent.innerHTML = '';
|
||||
} else if (data.status === 'detecting') {
|
||||
// Ignore detecting events if currently decoding (e.g. Doppler updates)
|
||||
const dot = document.getElementById('sstvGeneralStripDot');
|
||||
if (dot && dot.classList.contains('decoding')) return;
|
||||
|
||||
updateStatusUI('listening', data.message || 'Listening...');
|
||||
if (data.signal_level !== undefined) {
|
||||
renderSignalMonitor(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render signal monitor in live area during detecting mode
|
||||
*/
|
||||
function renderSignalMonitor(data) {
|
||||
const container = document.getElementById('sstvGeneralLiveContent');
|
||||
if (!container) return;
|
||||
|
||||
const level = data.signal_level || 0;
|
||||
const tone = data.sstv_tone;
|
||||
|
||||
let barColor, statusText;
|
||||
if (tone === 'leader') {
|
||||
barColor = 'var(--accent-green)';
|
||||
statusText = 'SSTV leader tone detected';
|
||||
} else if (tone === 'sync') {
|
||||
barColor = 'var(--accent-cyan)';
|
||||
statusText = 'SSTV sync pulse detected';
|
||||
} else if (tone === 'noise') {
|
||||
barColor = 'var(--text-dim)';
|
||||
statusText = 'Audio signal present';
|
||||
} else if (level > 10) {
|
||||
barColor = 'var(--text-dim)';
|
||||
statusText = 'Audio signal present';
|
||||
} else {
|
||||
barColor = 'var(--text-dim)';
|
||||
statusText = 'No signal';
|
||||
}
|
||||
|
||||
let monitor = container.querySelector('.sstv-general-signal-monitor');
|
||||
if (!monitor) {
|
||||
container.innerHTML = `
|
||||
<div class="sstv-general-signal-monitor">
|
||||
<div class="sstv-general-signal-monitor-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M2 12L5 12M5 12C5 12 6 3 12 3C18 3 19 12 19 12M19 12L22 12"/>
|
||||
<circle cx="12" cy="18" r="2"/>
|
||||
<path d="M12 16V12"/>
|
||||
</svg>
|
||||
Signal Monitor
|
||||
</div>
|
||||
<div class="sstv-general-signal-level-row">
|
||||
<span class="sstv-general-signal-level-label">LEVEL</span>
|
||||
<div class="sstv-general-signal-bar-track">
|
||||
<div class="sstv-general-signal-bar-fill" style="width: 0%"></div>
|
||||
</div>
|
||||
<span class="sstv-general-signal-level-value">0</span>
|
||||
</div>
|
||||
<div class="sstv-general-signal-status-text">No signal</div>
|
||||
<div class="sstv-general-signal-vis-state">VIS: idle</div>
|
||||
</div>`;
|
||||
monitor = container.querySelector('.sstv-general-signal-monitor');
|
||||
}
|
||||
|
||||
const fill = monitor.querySelector('.sstv-general-signal-bar-fill');
|
||||
fill.style.width = level + '%';
|
||||
fill.style.background = barColor;
|
||||
monitor.querySelector('.sstv-general-signal-status-text').textContent = statusText;
|
||||
monitor.querySelector('.sstv-general-signal-level-value').textContent = level;
|
||||
|
||||
const visStateEl = monitor.querySelector('.sstv-general-signal-vis-state');
|
||||
if (visStateEl && data.vis_state) {
|
||||
const stateLabels = {
|
||||
'idle': 'Idle',
|
||||
'leader_1': 'Leader',
|
||||
'break': 'Break',
|
||||
'leader_2': 'Leader 2',
|
||||
'start_bit': 'Start bit',
|
||||
'data_bits': 'Data bits',
|
||||
'parity': 'Parity',
|
||||
'stop_bit': 'Stop bit',
|
||||
};
|
||||
const label = stateLabels[data.vis_state] || data.vis_state;
|
||||
visStateEl.textContent = 'VIS: ' + label;
|
||||
visStateEl.className = 'sstv-general-signal-vis-state' +
|
||||
(data.vis_state !== 'idle' ? ' active' : '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render decode progress in live area
|
||||
*/
|
||||
function renderDecodeProgress(data) {
|
||||
const liveContent = document.getElementById('sstvGeneralLiveContent');
|
||||
if (!liveContent) return;
|
||||
|
||||
let container = liveContent.querySelector('.sstv-general-decode-container');
|
||||
if (!container) {
|
||||
liveContent.innerHTML = `
|
||||
<div class="sstv-general-decode-container">
|
||||
<div class="sstv-general-canvas-container">
|
||||
<img id="sstvGeneralDecodeImg" width="320" height="256" alt="Decoding..." style="display:block;background:#000;">
|
||||
</div>
|
||||
<div class="sstv-general-decode-info">
|
||||
<div class="sstv-general-mode-label"></div>
|
||||
<div class="sstv-general-progress-bar">
|
||||
<div class="progress" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="sstv-general-status-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container = liveContent.querySelector('.sstv-general-decode-container');
|
||||
}
|
||||
|
||||
container.querySelector('.sstv-general-mode-label').textContent = data.mode || 'Detecting mode...';
|
||||
container.querySelector('.progress').style.width = (data.progress || 0) + '%';
|
||||
container.querySelector('.sstv-general-status-message').textContent = data.message || 'Decoding...';
|
||||
|
||||
if (data.partial_image) {
|
||||
const img = container.querySelector('#sstvGeneralDecodeImg');
|
||||
if (img) img.src = data.partial_image;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load decoded images
|
||||
*/
|
||||
async function loadImages() {
|
||||
try {
|
||||
const response = await fetch('/sstv-general/images');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'ok') {
|
||||
images = data.images || [];
|
||||
updateImageCount(images.length);
|
||||
renderGallery();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load SSTV General images:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update image count display
|
||||
*/
|
||||
function updateImageCount(count) {
|
||||
const countEl = document.getElementById('sstvGeneralImageCount');
|
||||
const stripCount = document.getElementById('sstvGeneralStripImageCount');
|
||||
|
||||
if (countEl) countEl.textContent = count;
|
||||
if (stripCount) stripCount.textContent = count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render image gallery
|
||||
*/
|
||||
function renderGallery() {
|
||||
const gallery = document.getElementById('sstvGeneralGallery');
|
||||
if (!gallery) return;
|
||||
|
||||
if (images.length === 0) {
|
||||
gallery.innerHTML = `
|
||||
<div class="sstv-general-gallery-empty">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
<p>No images decoded yet</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
gallery.innerHTML = images.map(img => `
|
||||
<div class="sstv-general-image-card">
|
||||
<div class="sstv-general-image-card-inner" onclick="SSTVGeneral.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')">
|
||||
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-general-image-preview" loading="lazy">
|
||||
</div>
|
||||
<div class="sstv-general-image-info">
|
||||
<div class="sstv-general-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
|
||||
<div class="sstv-general-image-timestamp">${formatTimestamp(img.timestamp)}</div>
|
||||
</div>
|
||||
<div class="sstv-general-image-actions">
|
||||
<button onclick="event.stopPropagation(); SSTVGeneral.downloadImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')" title="Download">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
<button onclick="event.stopPropagation(); SSTVGeneral.deleteImage('${escapeHtml(img.filename)}')" title="Delete">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show full-size image in modal
|
||||
*/
|
||||
let currentModalUrl = null;
|
||||
let currentModalFilename = null;
|
||||
|
||||
function showImage(url, filename) {
|
||||
currentModalUrl = url;
|
||||
currentModalFilename = filename || null;
|
||||
|
||||
let modal = document.getElementById('sstvGeneralImageModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'sstvGeneralImageModal';
|
||||
modal.className = 'sstv-general-image-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="sstv-general-modal-toolbar">
|
||||
<button class="sstv-general-modal-btn" id="sstvGeneralModalDownload" title="Download">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Download
|
||||
</button>
|
||||
<button class="sstv-general-modal-btn delete" id="sstvGeneralModalDelete" title="Delete">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<button class="sstv-general-modal-close" onclick="SSTVGeneral.closeImage()">×</button>
|
||||
<img src="" alt="SSTV Image">
|
||||
`;
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeImage();
|
||||
});
|
||||
modal.querySelector('#sstvGeneralModalDownload').addEventListener('click', () => {
|
||||
if (currentModalUrl && currentModalFilename) {
|
||||
downloadImage(currentModalUrl, currentModalFilename);
|
||||
}
|
||||
});
|
||||
modal.querySelector('#sstvGeneralModalDelete').addEventListener('click', () => {
|
||||
if (currentModalFilename) {
|
||||
deleteImage(currentModalFilename);
|
||||
}
|
||||
});
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
modal.querySelector('img').src = url;
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close image modal
|
||||
*/
|
||||
function closeImage() {
|
||||
const modal = document.getElementById('sstvGeneralImageModal');
|
||||
if (modal) modal.classList.remove('show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp for display
|
||||
*/
|
||||
function formatTimestamp(isoString) {
|
||||
if (!isoString) return '--';
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML for safe display
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single image
|
||||
*/
|
||||
async function deleteImage(filename) {
|
||||
if (!confirm('Delete this image?')) return;
|
||||
try {
|
||||
const response = await fetch(`/sstv-general/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
images = images.filter(img => img.filename !== filename);
|
||||
updateImageCount(images.length);
|
||||
renderGallery();
|
||||
closeImage();
|
||||
showNotification('SSTV', 'Image deleted');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete image:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all images
|
||||
*/
|
||||
async function deleteAllImages() {
|
||||
if (!confirm('Delete all decoded images?')) return;
|
||||
try {
|
||||
const response = await fetch('/sstv-general/images', { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
images = [];
|
||||
updateImageCount(0);
|
||||
renderGallery();
|
||||
showNotification('SSTV', `${data.deleted} image${data.deleted !== 1 ? 's' : ''} deleted`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete images:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an image
|
||||
*/
|
||||
function downloadImage(url, filename) {
|
||||
const a = document.createElement('a');
|
||||
a.href = url + '/download';
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show status message
|
||||
*/
|
||||
function showStatusMessage(message, type) {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('SSTV', message);
|
||||
} else {
|
||||
console.log(`[SSTV General ${type}] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
start,
|
||||
stop,
|
||||
loadImages,
|
||||
showImage,
|
||||
closeImage,
|
||||
deleteImage,
|
||||
deleteAllImages,
|
||||
downloadImage,
|
||||
selectPreset
|
||||
};
|
||||
})();
|
||||
@@ -183,11 +183,11 @@ const SSTV = (function() {
|
||||
Settings.registerMap(issMap);
|
||||
} else {
|
||||
// Fallback to dark theme tiles
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
className: 'tile-layer-cyan'
|
||||
}).addTo(issMap);
|
||||
}
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
className: 'tile-layer-cyan'
|
||||
}).addTo(issMap);
|
||||
}
|
||||
|
||||
// Create ISS icon
|
||||
const issIcon = L.divIcon({
|
||||
@@ -491,7 +491,7 @@ const SSTV = (function() {
|
||||
|
||||
if (!data.available) {
|
||||
updateStatusUI('unavailable', 'Decoder not installed');
|
||||
showStatusMessage('SSTV decoder not available. Install slowrx: apt install slowrx', 'warning');
|
||||
showStatusMessage('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -521,6 +521,11 @@ const SSTV = (function() {
|
||||
const frequency = parseFloat(freqInput?.value || ISS_FREQ);
|
||||
const device = parseInt(deviceSelect?.value || '0', 10);
|
||||
|
||||
// Check if device is available
|
||||
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('sstv')) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatusUI('connecting', 'Starting...');
|
||||
|
||||
try {
|
||||
@@ -534,6 +539,9 @@ const SSTV = (function() {
|
||||
|
||||
if (data.status === 'started' || data.status === 'already_running') {
|
||||
isRunning = true;
|
||||
if (typeof reserveDevice === 'function') {
|
||||
reserveDevice(device, 'sstv');
|
||||
}
|
||||
updateStatusUI('listening', `${frequency} MHz`);
|
||||
startStream();
|
||||
showNotification('SSTV', `Listening on ${frequency} MHz`);
|
||||
@@ -555,6 +563,9 @@ const SSTV = (function() {
|
||||
try {
|
||||
await fetch('/sstv/stop', { method: 'POST' });
|
||||
isRunning = false;
|
||||
if (typeof releaseDevice === 'function') {
|
||||
releaseDevice('sstv');
|
||||
}
|
||||
stopStream();
|
||||
updateStatusUI('idle', 'Stopped');
|
||||
showNotification('SSTV', 'Decoder stopped');
|
||||
@@ -680,8 +691,96 @@ const SSTV = (function() {
|
||||
renderGallery();
|
||||
showNotification('SSTV', 'New image decoded!');
|
||||
updateStatusUI('listening', 'Listening...');
|
||||
// Clear decode progress so signal monitor can take over
|
||||
const liveContent = document.getElementById('sstvLiveContent');
|
||||
if (liveContent) liveContent.innerHTML = '';
|
||||
} else if (data.status === 'detecting') {
|
||||
// Ignore detecting events if currently decoding (e.g. Doppler updates)
|
||||
const dot = document.getElementById('sstvStripDot');
|
||||
if (dot && dot.classList.contains('decoding')) return;
|
||||
|
||||
updateStatusUI('listening', data.message || 'Listening...');
|
||||
if (data.signal_level !== undefined) {
|
||||
renderSignalMonitor(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render signal monitor in live area during detecting mode
|
||||
*/
|
||||
function renderSignalMonitor(data) {
|
||||
const container = document.getElementById('sstvLiveContent');
|
||||
if (!container) return;
|
||||
|
||||
const level = data.signal_level || 0;
|
||||
const tone = data.sstv_tone;
|
||||
|
||||
let barColor, statusText;
|
||||
if (tone === 'leader') {
|
||||
barColor = 'var(--accent-green)';
|
||||
statusText = 'SSTV leader tone detected';
|
||||
} else if (tone === 'sync') {
|
||||
barColor = 'var(--accent-cyan)';
|
||||
statusText = 'SSTV sync pulse detected';
|
||||
} else if (tone === 'noise') {
|
||||
barColor = 'var(--text-dim)';
|
||||
statusText = 'Audio signal present';
|
||||
} else if (level > 10) {
|
||||
barColor = 'var(--text-dim)';
|
||||
statusText = 'Audio signal present';
|
||||
} else {
|
||||
barColor = 'var(--text-dim)';
|
||||
statusText = 'No signal';
|
||||
}
|
||||
|
||||
let monitor = container.querySelector('.sstv-signal-monitor');
|
||||
if (!monitor) {
|
||||
container.innerHTML = `
|
||||
<div class="sstv-signal-monitor">
|
||||
<div class="sstv-signal-monitor-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M2 12L5 12M5 12C5 12 6 3 12 3C18 3 19 12 19 12M19 12L22 12"/>
|
||||
<circle cx="12" cy="18" r="2"/>
|
||||
<path d="M12 16V12"/>
|
||||
</svg>
|
||||
Signal Monitor
|
||||
</div>
|
||||
<div class="sstv-signal-level-row">
|
||||
<span class="sstv-signal-level-label">LEVEL</span>
|
||||
<div class="sstv-signal-bar-track">
|
||||
<div class="sstv-signal-bar-fill" style="width: 0%"></div>
|
||||
</div>
|
||||
<span class="sstv-signal-level-value">0</span>
|
||||
</div>
|
||||
<div class="sstv-signal-status-text">No signal</div>
|
||||
<div class="sstv-signal-vis-state">VIS: idle</div>
|
||||
</div>`;
|
||||
monitor = container.querySelector('.sstv-signal-monitor');
|
||||
}
|
||||
|
||||
const fill = monitor.querySelector('.sstv-signal-bar-fill');
|
||||
fill.style.width = level + '%';
|
||||
fill.style.background = barColor;
|
||||
monitor.querySelector('.sstv-signal-status-text').textContent = statusText;
|
||||
monitor.querySelector('.sstv-signal-level-value').textContent = level;
|
||||
|
||||
const visStateEl = monitor.querySelector('.sstv-signal-vis-state');
|
||||
if (visStateEl && data.vis_state) {
|
||||
const stateLabels = {
|
||||
'idle': 'Idle',
|
||||
'leader_1': 'Leader',
|
||||
'break': 'Break',
|
||||
'leader_2': 'Leader 2',
|
||||
'start_bit': 'Start bit',
|
||||
'data_bits': 'Data bits',
|
||||
'parity': 'Parity',
|
||||
'stop_bit': 'Stop bit',
|
||||
};
|
||||
const label = stateLabels[data.vis_state] || data.vis_state;
|
||||
visStateEl.textContent = 'VIS: ' + label;
|
||||
visStateEl.className = 'sstv-signal-vis-state' +
|
||||
(data.vis_state !== 'idle' ? ' active' : '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -692,18 +791,33 @@ const SSTV = (function() {
|
||||
const liveContent = document.getElementById('sstvLiveContent');
|
||||
if (!liveContent) return;
|
||||
|
||||
liveContent.innerHTML = `
|
||||
<div class="sstv-canvas-container">
|
||||
<canvas id="sstvCanvas" width="320" height="256"></canvas>
|
||||
</div>
|
||||
<div class="sstv-decode-info">
|
||||
<div class="sstv-mode-label">${data.mode || 'Detecting mode...'}</div>
|
||||
<div class="sstv-progress-bar">
|
||||
<div class="progress" style="width: ${data.progress || 0}%"></div>
|
||||
let container = liveContent.querySelector('.sstv-decode-container');
|
||||
if (!container) {
|
||||
liveContent.innerHTML = `
|
||||
<div class="sstv-decode-container">
|
||||
<div class="sstv-canvas-container">
|
||||
<img id="sstvDecodeImg" width="320" height="256" alt="Decoding..." style="display:block;background:#000;">
|
||||
</div>
|
||||
<div class="sstv-decode-info">
|
||||
<div class="sstv-mode-label"></div>
|
||||
<div class="sstv-progress-bar">
|
||||
<div class="progress" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="sstv-status-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sstv-status-message">${data.message || 'Decoding...'}</div>
|
||||
</div>
|
||||
`;
|
||||
`;
|
||||
container = liveContent.querySelector('.sstv-decode-container');
|
||||
}
|
||||
|
||||
container.querySelector('.sstv-mode-label').textContent = data.mode || 'Detecting mode...';
|
||||
container.querySelector('.progress').style.width = (data.progress || 0) + '%';
|
||||
container.querySelector('.sstv-status-message').textContent = data.message || 'Decoding...';
|
||||
|
||||
if (data.partial_image) {
|
||||
const img = container.querySelector('#sstvDecodeImg');
|
||||
if (img) img.src = data.partial_image;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -757,12 +871,22 @@ const SSTV = (function() {
|
||||
}
|
||||
|
||||
gallery.innerHTML = images.map(img => `
|
||||
<div class="sstv-image-card" onclick="SSTV.showImage('${escapeHtml(img.url)}')">
|
||||
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-image-preview" loading="lazy">
|
||||
<div class="sstv-image-card">
|
||||
<div class="sstv-image-card-inner" onclick="SSTV.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')">
|
||||
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-image-preview" loading="lazy">
|
||||
</div>
|
||||
<div class="sstv-image-info">
|
||||
<div class="sstv-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
|
||||
<div class="sstv-image-timestamp">${formatTimestamp(img.timestamp)}</div>
|
||||
</div>
|
||||
<div class="sstv-image-actions">
|
||||
<button onclick="event.stopPropagation(); SSTV.downloadImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')" title="Download">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
<button onclick="event.stopPropagation(); SSTV.deleteImage('${escapeHtml(img.filename)}')" title="Delete">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
@@ -894,19 +1018,45 @@ const SSTV = (function() {
|
||||
/**
|
||||
* Show full-size image in modal
|
||||
*/
|
||||
function showImage(url) {
|
||||
let currentModalUrl = null;
|
||||
let currentModalFilename = null;
|
||||
|
||||
function showImage(url, filename) {
|
||||
currentModalUrl = url;
|
||||
currentModalFilename = filename || null;
|
||||
|
||||
let modal = document.getElementById('sstvImageModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'sstvImageModal';
|
||||
modal.className = 'sstv-image-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="sstv-modal-toolbar">
|
||||
<button class="sstv-modal-btn" id="sstvModalDownload" title="Download">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Download
|
||||
</button>
|
||||
<button class="sstv-modal-btn delete" id="sstvModalDelete" title="Delete">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<button class="sstv-modal-close" onclick="SSTV.closeImage()">×</button>
|
||||
<img src="" alt="SSTV Image">
|
||||
`;
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeImage();
|
||||
});
|
||||
modal.querySelector('#sstvModalDownload').addEventListener('click', () => {
|
||||
if (currentModalUrl && currentModalFilename) {
|
||||
downloadImage(currentModalUrl, currentModalFilename);
|
||||
}
|
||||
});
|
||||
modal.querySelector('#sstvModalDelete').addEventListener('click', () => {
|
||||
if (currentModalFilename) {
|
||||
deleteImage(currentModalFilename);
|
||||
}
|
||||
});
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
@@ -945,6 +1095,55 @@ const SSTV = (function() {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single image
|
||||
*/
|
||||
async function deleteImage(filename) {
|
||||
if (!confirm('Delete this image?')) return;
|
||||
try {
|
||||
const response = await fetch(`/sstv/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
images = images.filter(img => img.filename !== filename);
|
||||
updateImageCount(images.length);
|
||||
renderGallery();
|
||||
closeImage();
|
||||
showNotification('SSTV', 'Image deleted');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete image:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all images
|
||||
*/
|
||||
async function deleteAllImages() {
|
||||
if (!confirm('Delete all decoded images?')) return;
|
||||
try {
|
||||
const response = await fetch('/sstv/images', { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
images = [];
|
||||
updateImageCount(0);
|
||||
renderGallery();
|
||||
showNotification('SSTV', `${data.deleted} image${data.deleted !== 1 ? 's' : ''} deleted`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete images:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an image
|
||||
*/
|
||||
function downloadImage(url, filename) {
|
||||
const a = document.createElement('a');
|
||||
a.href = url + '/download';
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show status message
|
||||
*/
|
||||
@@ -965,6 +1164,9 @@ const SSTV = (function() {
|
||||
loadIssSchedule,
|
||||
showImage,
|
||||
closeImage,
|
||||
deleteImage,
|
||||
deleteAllImages,
|
||||
downloadImage,
|
||||
useGPS,
|
||||
updateTLE,
|
||||
stopIssTracking,
|
||||
|
||||
581
static/js/modes/websdr.js
Normal file
581
static/js/modes/websdr.js
Normal file
@@ -0,0 +1,581 @@
|
||||
/**
|
||||
* Intercept - WebSDR Mode
|
||||
* HF/Shortwave KiwiSDR Network Integration with In-App Audio
|
||||
*/
|
||||
|
||||
// ============== STATE ==============
|
||||
let websdrMap = null;
|
||||
let websdrMarkers = [];
|
||||
let websdrReceivers = [];
|
||||
let websdrInitialized = false;
|
||||
let websdrSpyStationsLoaded = false;
|
||||
|
||||
// KiwiSDR audio state
|
||||
let kiwiWebSocket = null;
|
||||
let kiwiAudioContext = null;
|
||||
let kiwiScriptProcessor = null;
|
||||
let kiwiGainNode = null;
|
||||
let kiwiAudioBuffer = [];
|
||||
let kiwiConnected = false;
|
||||
let kiwiCurrentFreq = 0;
|
||||
let kiwiCurrentMode = 'am';
|
||||
let kiwiSmeter = 0;
|
||||
let kiwiSmeterInterval = null;
|
||||
let kiwiReceiverName = '';
|
||||
|
||||
const KIWI_SAMPLE_RATE = 12000;
|
||||
|
||||
// ============== INITIALIZATION ==============
|
||||
|
||||
function initWebSDR() {
|
||||
if (websdrInitialized) {
|
||||
if (websdrMap) {
|
||||
setTimeout(() => websdrMap.invalidateSize(), 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const mapEl = document.getElementById('websdrMap');
|
||||
if (!mapEl || typeof L === 'undefined') return;
|
||||
|
||||
// Calculate minimum zoom so tiles fill the container vertically
|
||||
const mapHeight = mapEl.clientHeight || 500;
|
||||
const minZoom = Math.ceil(Math.log2(mapHeight / 256));
|
||||
|
||||
websdrMap = L.map('websdrMap', {
|
||||
center: [20, 0],
|
||||
zoom: Math.max(minZoom, 2),
|
||||
minZoom: Math.max(minZoom, 2),
|
||||
zoomControl: true,
|
||||
maxBounds: [[-85, -360], [85, 360]],
|
||||
maxBoundsViscosity: 1.0,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors © CARTO',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19,
|
||||
}).addTo(websdrMap);
|
||||
|
||||
// Match background to tile ocean color so any remaining edge is seamless
|
||||
mapEl.style.background = '#1a1d29';
|
||||
|
||||
websdrInitialized = true;
|
||||
|
||||
if (!websdrSpyStationsLoaded) {
|
||||
loadSpyStationPresets();
|
||||
}
|
||||
|
||||
[100, 300, 600, 1000].forEach(delay => {
|
||||
setTimeout(() => {
|
||||
if (websdrMap) websdrMap.invalidateSize();
|
||||
}, delay);
|
||||
});
|
||||
}
|
||||
|
||||
// ============== RECEIVER SEARCH ==============
|
||||
|
||||
function searchReceivers(refresh) {
|
||||
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 0);
|
||||
|
||||
let url = '/websdr/receivers?available=true';
|
||||
if (freqKhz > 0) url += `&freq_khz=${freqKhz}`;
|
||||
if (refresh) url += '&refresh=true';
|
||||
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
websdrReceivers = data.receivers || [];
|
||||
renderReceiverList(websdrReceivers);
|
||||
plotReceiversOnMap(websdrReceivers);
|
||||
|
||||
const countEl = document.getElementById('websdrReceiverCount');
|
||||
if (countEl) countEl.textContent = `${websdrReceivers.length} found`;
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[WEBSDR] Search error:', err));
|
||||
}
|
||||
|
||||
// ============== MAP ==============
|
||||
|
||||
function plotReceiversOnMap(receivers) {
|
||||
if (!websdrMap) return;
|
||||
|
||||
websdrMarkers.forEach(m => websdrMap.removeLayer(m));
|
||||
websdrMarkers = [];
|
||||
|
||||
receivers.forEach((rx, idx) => {
|
||||
if (rx.lat == null || rx.lon == null) return;
|
||||
|
||||
const marker = L.circleMarker([rx.lat, rx.lon], {
|
||||
radius: 6,
|
||||
fillColor: rx.available ? '#00d4ff' : '#666',
|
||||
color: rx.available ? '#00d4ff' : '#666',
|
||||
weight: 1,
|
||||
opacity: 0.8,
|
||||
fillOpacity: 0.6,
|
||||
});
|
||||
|
||||
marker.bindPopup(`
|
||||
<div style="font-size: 12px; min-width: 200px;">
|
||||
<strong>${escapeHtmlWebsdr(rx.name)}</strong><br>
|
||||
${rx.location ? `<span style="color: #aaa;">${escapeHtmlWebsdr(rx.location)}</span><br>` : ''}
|
||||
<span style="color: #888;">Antenna: ${escapeHtmlWebsdr(rx.antenna || 'Unknown')}</span><br>
|
||||
<span style="color: #888;">Users: ${rx.users}/${rx.users_max}</span><br>
|
||||
<button onclick="selectReceiver(${idx})" style="margin-top: 6px; padding: 4px 12px; background: #00d4ff; color: #000; border: none; border-radius: 3px; cursor: pointer; font-weight: bold;">Listen</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
marker.addTo(websdrMap);
|
||||
websdrMarkers.push(marker);
|
||||
});
|
||||
|
||||
if (websdrMarkers.length > 0) {
|
||||
const group = L.featureGroup(websdrMarkers);
|
||||
websdrMap.fitBounds(group.getBounds(), { padding: [30, 30] });
|
||||
}
|
||||
}
|
||||
|
||||
// ============== RECEIVER LIST ==============
|
||||
|
||||
function renderReceiverList(receivers) {
|
||||
const container = document.getElementById('websdrReceiverList');
|
||||
if (!container) return;
|
||||
|
||||
if (receivers.length === 0) {
|
||||
container.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 20px;">No receivers found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = receivers.slice(0, 50).map((rx, idx) => `
|
||||
<div style="padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='rgba(0,212,255,0.05)'" onmouseout="this.style.background='transparent'"
|
||||
onclick="selectReceiver(${idx})">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<strong style="font-size: 11px; color: var(--text-primary);">${escapeHtmlWebsdr(rx.name)}</strong>
|
||||
<span style="font-size: 9px; padding: 1px 6px; background: ${rx.available ? 'rgba(0,230,118,0.15)' : 'rgba(158,158,158,0.15)'}; color: ${rx.available ? '#00e676' : '#9e9e9e'}; border-radius: 3px;">${rx.users}/${rx.users_max}</span>
|
||||
</div>
|
||||
<div style="font-size: 9px; color: var(--text-muted); margin-top: 2px;">
|
||||
${rx.location ? escapeHtmlWebsdr(rx.location) + ' · ' : ''}${escapeHtmlWebsdr(rx.antenna || '')}
|
||||
${rx.distance_km !== undefined ? ` · ${rx.distance_km} km` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ============== SELECT RECEIVER ==============
|
||||
|
||||
function selectReceiver(index) {
|
||||
const rx = websdrReceivers[index];
|
||||
if (!rx) return;
|
||||
|
||||
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 7000);
|
||||
const mode = document.getElementById('websdrMode_select')?.value || 'am';
|
||||
|
||||
kiwiReceiverName = rx.name;
|
||||
|
||||
// Connect via backend proxy
|
||||
connectToReceiver(rx.url, freqKhz, mode);
|
||||
|
||||
// Highlight on map
|
||||
if (websdrMap && rx.lat != null && rx.lon != null) {
|
||||
websdrMap.setView([rx.lat, rx.lon], 6);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== KIWISDR AUDIO CONNECTION ==============
|
||||
|
||||
function connectToReceiver(receiverUrl, freqKhz, mode) {
|
||||
// Disconnect if already connected
|
||||
if (kiwiWebSocket) {
|
||||
disconnectFromReceiver();
|
||||
}
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${proto}//${location.host}/ws/kiwi-audio`;
|
||||
|
||||
kiwiWebSocket = new WebSocket(wsUrl);
|
||||
kiwiWebSocket.binaryType = 'arraybuffer';
|
||||
|
||||
kiwiWebSocket.onopen = () => {
|
||||
kiwiWebSocket.send(JSON.stringify({
|
||||
cmd: 'connect',
|
||||
url: receiverUrl,
|
||||
freq_khz: freqKhz,
|
||||
mode: mode,
|
||||
}));
|
||||
updateKiwiUI('connecting');
|
||||
};
|
||||
|
||||
kiwiWebSocket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleKiwiStatus(msg);
|
||||
} else {
|
||||
handleKiwiAudio(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
kiwiWebSocket.onclose = () => {
|
||||
kiwiConnected = false;
|
||||
updateKiwiUI('disconnected');
|
||||
};
|
||||
|
||||
kiwiWebSocket.onerror = () => {
|
||||
updateKiwiUI('disconnected');
|
||||
};
|
||||
}
|
||||
|
||||
function handleKiwiStatus(msg) {
|
||||
switch (msg.type) {
|
||||
case 'connected':
|
||||
kiwiConnected = true;
|
||||
kiwiCurrentFreq = msg.freq_khz;
|
||||
kiwiCurrentMode = msg.mode;
|
||||
initKiwiAudioContext(msg.sample_rate || KIWI_SAMPLE_RATE);
|
||||
updateKiwiUI('connected');
|
||||
break;
|
||||
case 'tuned':
|
||||
kiwiCurrentFreq = msg.freq_khz;
|
||||
kiwiCurrentMode = msg.mode;
|
||||
updateKiwiUI('connected');
|
||||
break;
|
||||
case 'error':
|
||||
console.error('[KIWI] Error:', msg.message);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('WebSDR', msg.message);
|
||||
}
|
||||
updateKiwiUI('error');
|
||||
break;
|
||||
case 'disconnected':
|
||||
kiwiConnected = false;
|
||||
cleanupKiwiAudio();
|
||||
updateKiwiUI('disconnected');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKiwiAudio(arrayBuffer) {
|
||||
if (arrayBuffer.byteLength < 4) return;
|
||||
|
||||
// First 2 bytes: S-meter (big-endian int16)
|
||||
const view = new DataView(arrayBuffer);
|
||||
kiwiSmeter = view.getInt16(0, false);
|
||||
|
||||
// Remaining bytes: PCM 16-bit signed LE
|
||||
const pcmData = new Int16Array(arrayBuffer, 2);
|
||||
|
||||
// Convert to float32 [-1, 1] for Web Audio API
|
||||
const float32 = new Float32Array(pcmData.length);
|
||||
for (let i = 0; i < pcmData.length; i++) {
|
||||
float32[i] = pcmData[i] / 32768.0;
|
||||
}
|
||||
|
||||
// Add to playback buffer (limit buffer size to ~2s)
|
||||
kiwiAudioBuffer.push(float32);
|
||||
const maxChunks = Math.ceil((KIWI_SAMPLE_RATE * 2) / 512);
|
||||
while (kiwiAudioBuffer.length > maxChunks) {
|
||||
kiwiAudioBuffer.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function initKiwiAudioContext(sampleRate) {
|
||||
cleanupKiwiAudio();
|
||||
|
||||
kiwiAudioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||
sampleRate: sampleRate,
|
||||
});
|
||||
|
||||
// Resume if suspended (autoplay policy)
|
||||
if (kiwiAudioContext.state === 'suspended') {
|
||||
kiwiAudioContext.resume();
|
||||
}
|
||||
|
||||
// ScriptProcessorNode: pulls audio from buffer
|
||||
kiwiScriptProcessor = kiwiAudioContext.createScriptProcessor(2048, 0, 1);
|
||||
kiwiScriptProcessor.onaudioprocess = (e) => {
|
||||
const output = e.outputBuffer.getChannelData(0);
|
||||
let offset = 0;
|
||||
|
||||
while (offset < output.length && kiwiAudioBuffer.length > 0) {
|
||||
const chunk = kiwiAudioBuffer[0];
|
||||
const needed = output.length - offset;
|
||||
const available = chunk.length;
|
||||
|
||||
if (available <= needed) {
|
||||
output.set(chunk, offset);
|
||||
offset += available;
|
||||
kiwiAudioBuffer.shift();
|
||||
} else {
|
||||
output.set(chunk.subarray(0, needed), offset);
|
||||
kiwiAudioBuffer[0] = chunk.subarray(needed);
|
||||
offset += needed;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill remaining with silence
|
||||
while (offset < output.length) {
|
||||
output[offset++] = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Volume control
|
||||
kiwiGainNode = kiwiAudioContext.createGain();
|
||||
const savedVol = localStorage.getItem('kiwiVolume');
|
||||
kiwiGainNode.gain.value = savedVol !== null ? parseFloat(savedVol) / 100 : 0.8;
|
||||
const volValue = Math.round(kiwiGainNode.gain.value * 100);
|
||||
['kiwiVolume', 'kiwiBarVolume'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = volValue;
|
||||
});
|
||||
|
||||
kiwiScriptProcessor.connect(kiwiGainNode);
|
||||
kiwiGainNode.connect(kiwiAudioContext.destination);
|
||||
|
||||
// S-meter display updates
|
||||
if (kiwiSmeterInterval) clearInterval(kiwiSmeterInterval);
|
||||
kiwiSmeterInterval = setInterval(updateSmeterDisplay, 200);
|
||||
}
|
||||
|
||||
function disconnectFromReceiver() {
|
||||
if (kiwiWebSocket && kiwiWebSocket.readyState === WebSocket.OPEN) {
|
||||
kiwiWebSocket.send(JSON.stringify({ cmd: 'disconnect' }));
|
||||
}
|
||||
cleanupKiwiAudio();
|
||||
if (kiwiWebSocket) {
|
||||
kiwiWebSocket.close();
|
||||
kiwiWebSocket = null;
|
||||
}
|
||||
kiwiConnected = false;
|
||||
kiwiReceiverName = '';
|
||||
updateKiwiUI('disconnected');
|
||||
}
|
||||
|
||||
function cleanupKiwiAudio() {
|
||||
if (kiwiSmeterInterval) {
|
||||
clearInterval(kiwiSmeterInterval);
|
||||
kiwiSmeterInterval = null;
|
||||
}
|
||||
if (kiwiScriptProcessor) {
|
||||
kiwiScriptProcessor.disconnect();
|
||||
kiwiScriptProcessor = null;
|
||||
}
|
||||
if (kiwiGainNode) {
|
||||
kiwiGainNode.disconnect();
|
||||
kiwiGainNode = null;
|
||||
}
|
||||
if (kiwiAudioContext) {
|
||||
kiwiAudioContext.close().catch(() => {});
|
||||
kiwiAudioContext = null;
|
||||
}
|
||||
kiwiAudioBuffer = [];
|
||||
kiwiSmeter = 0;
|
||||
}
|
||||
|
||||
function tuneKiwi(freqKhz, mode) {
|
||||
if (!kiwiWebSocket || !kiwiConnected) return;
|
||||
kiwiWebSocket.send(JSON.stringify({
|
||||
cmd: 'tune',
|
||||
freq_khz: freqKhz,
|
||||
mode: mode || kiwiCurrentMode,
|
||||
}));
|
||||
}
|
||||
|
||||
function tuneFromBar() {
|
||||
const freq = parseFloat(document.getElementById('kiwiBarFrequency')?.value || 0);
|
||||
const mode = document.getElementById('kiwiBarMode')?.value || kiwiCurrentMode;
|
||||
if (freq > 0) {
|
||||
tuneKiwi(freq, mode);
|
||||
// Also update sidebar frequency
|
||||
const freqInput = document.getElementById('websdrFrequency');
|
||||
if (freqInput) freqInput.value = freq;
|
||||
}
|
||||
}
|
||||
|
||||
function setKiwiVolume(value) {
|
||||
if (kiwiGainNode) {
|
||||
kiwiGainNode.gain.value = value / 100;
|
||||
localStorage.setItem('kiwiVolume', value);
|
||||
}
|
||||
// Sync both volume sliders
|
||||
['kiwiVolume', 'kiwiBarVolume'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el && el.value !== String(value)) el.value = value;
|
||||
});
|
||||
}
|
||||
|
||||
// ============== S-METER ==============
|
||||
|
||||
function updateSmeterDisplay() {
|
||||
// KiwiSDR S-meter: value in 0.1 dBm units (e.g., -730 = -73 dBm = S9)
|
||||
const dbm = kiwiSmeter / 10;
|
||||
let sUnit;
|
||||
if (dbm >= -73) {
|
||||
const over = Math.round((dbm + 73));
|
||||
sUnit = over > 0 ? `S9+${over}` : 'S9';
|
||||
} else {
|
||||
sUnit = `S${Math.max(0, Math.round((dbm + 127) / 6))}`;
|
||||
}
|
||||
|
||||
const pct = Math.min(100, Math.max(0, (dbm + 127) / 1.27));
|
||||
|
||||
// Update both sidebar and bar S-meter displays
|
||||
['kiwiSmeterBar', 'kiwiBarSmeter'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.width = pct + '%';
|
||||
});
|
||||
['kiwiSmeterValue', 'kiwiBarSmeterValue'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = sUnit;
|
||||
});
|
||||
}
|
||||
|
||||
// ============== UI UPDATES ==============
|
||||
|
||||
function updateKiwiUI(state) {
|
||||
const statusEl = document.getElementById('kiwiStatus');
|
||||
const controlsBar = document.getElementById('kiwiAudioControls');
|
||||
const disconnectBtn = document.getElementById('kiwiDisconnectBtn');
|
||||
const receiverNameEl = document.getElementById('kiwiReceiverName');
|
||||
const freqDisplay = document.getElementById('kiwiFreqDisplay');
|
||||
const barReceiverName = document.getElementById('kiwiBarReceiverName');
|
||||
const barFreq = document.getElementById('kiwiBarFrequency');
|
||||
const barMode = document.getElementById('kiwiBarMode');
|
||||
|
||||
if (state === 'connected') {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'CONNECTED';
|
||||
statusEl.style.color = 'var(--accent-green)';
|
||||
}
|
||||
if (controlsBar) controlsBar.style.display = 'block';
|
||||
if (disconnectBtn) disconnectBtn.style.display = 'block';
|
||||
if (receiverNameEl) {
|
||||
receiverNameEl.textContent = kiwiReceiverName;
|
||||
receiverNameEl.style.display = 'block';
|
||||
}
|
||||
if (freqDisplay) freqDisplay.textContent = kiwiCurrentFreq + ' kHz';
|
||||
if (barReceiverName) barReceiverName.textContent = kiwiReceiverName;
|
||||
if (barFreq) barFreq.value = kiwiCurrentFreq;
|
||||
if (barMode) barMode.value = kiwiCurrentMode;
|
||||
} else if (state === 'connecting') {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'CONNECTING...';
|
||||
statusEl.style.color = 'var(--accent-orange)';
|
||||
}
|
||||
} else if (state === 'error') {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'ERROR';
|
||||
statusEl.style.color = 'var(--accent-red)';
|
||||
}
|
||||
} else {
|
||||
// disconnected
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'DISCONNECTED';
|
||||
statusEl.style.color = 'var(--text-muted)';
|
||||
}
|
||||
if (controlsBar) controlsBar.style.display = 'none';
|
||||
if (disconnectBtn) disconnectBtn.style.display = 'none';
|
||||
if (receiverNameEl) receiverNameEl.style.display = 'none';
|
||||
if (freqDisplay) freqDisplay.textContent = '--- kHz';
|
||||
// Reset both S-meter displays (sidebar + bar)
|
||||
['kiwiSmeterBar', 'kiwiBarSmeter'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.width = '0%';
|
||||
});
|
||||
['kiwiSmeterValue', 'kiwiBarSmeterValue'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = 'S0';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============== SPY STATION PRESETS ==============
|
||||
|
||||
function loadSpyStationPresets() {
|
||||
fetch('/spy-stations/stations')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
websdrSpyStationsLoaded = true;
|
||||
const container = document.getElementById('websdrSpyPresets');
|
||||
if (!container) return;
|
||||
|
||||
const stations = data.stations || data || [];
|
||||
if (!Array.isArray(stations) || stations.length === 0) {
|
||||
container.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 10px;">No stations available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = stations.slice(0, 30).map(s => {
|
||||
const primaryFreq = s.frequencies?.find(f => f.primary) || s.frequencies?.[0];
|
||||
const freqKhz = primaryFreq?.freq_khz || 0;
|
||||
return `
|
||||
<div style="padding: 6px 4px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; display: flex; justify-content: space-between; align-items: center;"
|
||||
onclick="tuneToSpyStation('${escapeHtmlWebsdr(s.id)}', ${freqKhz})"
|
||||
onmouseover="this.style.background='rgba(0,212,255,0.05)'" onmouseout="this.style.background='transparent'">
|
||||
<div>
|
||||
<span style="color: var(--accent-cyan); font-weight: bold;">${escapeHtmlWebsdr(s.name)}</span>
|
||||
<span style="color: var(--text-muted); font-size: 9px; margin-left: 4px;">${escapeHtmlWebsdr(s.nickname || '')}</span>
|
||||
</div>
|
||||
<span style="color: var(--accent-orange); font-family: var(--font-mono); font-size: 10px;">${freqKhz} kHz</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[WEBSDR] Failed to load spy station presets:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function tuneToSpyStation(stationId, freqKhz) {
|
||||
const freqInput = document.getElementById('websdrFrequency');
|
||||
if (freqInput) freqInput.value = freqKhz;
|
||||
|
||||
// If already connected, just retune
|
||||
if (kiwiConnected) {
|
||||
const mode = document.getElementById('websdrMode_select')?.value || kiwiCurrentMode;
|
||||
tuneKiwi(freqKhz, mode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, search for receivers at this frequency
|
||||
fetch(`/websdr/spy-station/${encodeURIComponent(stationId)}/receivers`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
websdrReceivers = data.receivers || [];
|
||||
renderReceiverList(websdrReceivers);
|
||||
plotReceiversOnMap(websdrReceivers);
|
||||
|
||||
const countEl = document.getElementById('websdrReceiverCount');
|
||||
if (countEl) countEl.textContent = `${websdrReceivers.length} for ${data.station?.name || stationId}`;
|
||||
|
||||
if (typeof showNotification === 'function' && data.station) {
|
||||
showNotification('WebSDR', `Found ${websdrReceivers.length} receivers for ${data.station.name} at ${freqKhz} kHz`);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[WEBSDR] Spy station receivers error:', err));
|
||||
}
|
||||
|
||||
// ============== UTILITIES ==============
|
||||
|
||||
function escapeHtmlWebsdr(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ============== EXPORTS ==============
|
||||
|
||||
window.initWebSDR = initWebSDR;
|
||||
window.searchReceivers = searchReceivers;
|
||||
window.selectReceiver = selectReceiver;
|
||||
window.tuneToSpyStation = tuneToSpyStation;
|
||||
window.loadSpyStationPresets = loadSpyStationPresets;
|
||||
window.connectToReceiver = connectToReceiver;
|
||||
window.disconnectFromReceiver = disconnectFromReceiver;
|
||||
window.tuneKiwi = tuneKiwi;
|
||||
window.tuneFromBar = tuneFromBar;
|
||||
window.setKiwiVolume = setKiwiVolume;
|
||||
@@ -28,9 +28,9 @@ const WiFiMode = (function() {
|
||||
maxProbes: 1000,
|
||||
};
|
||||
|
||||
// ==========================================================================
|
||||
// Agent Support
|
||||
// ==========================================================================
|
||||
// ==========================================================================
|
||||
// Agent Support
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get the API base URL, routing through agent proxy if agent is selected.
|
||||
@@ -59,15 +59,49 @@ const WiFiMode = (function() {
|
||||
/**
|
||||
* Check for agent mode conflicts before starting WiFi scan.
|
||||
*/
|
||||
function checkAgentConflicts() {
|
||||
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
||||
return true;
|
||||
}
|
||||
if (typeof checkAgentModeConflict === 'function') {
|
||||
return checkAgentModeConflict('wifi');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function checkAgentConflicts() {
|
||||
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
||||
return true;
|
||||
}
|
||||
if (typeof checkAgentModeConflict === 'function') {
|
||||
return checkAgentModeConflict('wifi');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getChannelPresetList(preset) {
|
||||
switch (preset) {
|
||||
case '2.4-common':
|
||||
return '1,6,11';
|
||||
case '2.4-all':
|
||||
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
|
||||
case '5-low':
|
||||
return '36,40,44,48';
|
||||
case '5-mid':
|
||||
return '52,56,60,64';
|
||||
case '5-high':
|
||||
return '149,153,157,161,165';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function buildChannelConfig() {
|
||||
const preset = document.getElementById('wifiChannelPreset')?.value || '';
|
||||
const listInput = document.getElementById('wifiChannelList')?.value || '';
|
||||
const singleInput = document.getElementById('wifiChannel')?.value || '';
|
||||
|
||||
const listValue = listInput.trim();
|
||||
const presetValue = getChannelPresetList(preset);
|
||||
|
||||
const channels = listValue || presetValue || '';
|
||||
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
|
||||
|
||||
return {
|
||||
channels: channels || null,
|
||||
channel: Number.isFinite(channel) ? channel : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// State
|
||||
@@ -461,10 +495,10 @@ const WiFiMode = (function() {
|
||||
setScanning(true, 'deep');
|
||||
|
||||
try {
|
||||
const iface = elements.interfaceSelect?.value || null;
|
||||
const band = document.getElementById('wifiBand')?.value || 'all';
|
||||
const channel = document.getElementById('wifiChannel')?.value || null;
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
const iface = elements.interfaceSelect?.value || null;
|
||||
const band = document.getElementById('wifiBand')?.value || 'all';
|
||||
const channelConfig = buildChannelConfig();
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
|
||||
let response;
|
||||
if (isAgentMode) {
|
||||
@@ -473,23 +507,25 @@ const WiFiMode = (function() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
interface: iface,
|
||||
scan_type: 'deep',
|
||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||
channel: channel ? parseInt(channel) : null,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
interface: iface,
|
||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||
channel: channel ? parseInt(channel) : null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
interface: iface,
|
||||
scan_type: 'deep',
|
||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||
channel: channelConfig.channel,
|
||||
channels: channelConfig.channels,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
interface: iface,
|
||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||
channel: channelConfig.channel,
|
||||
channels: channelConfig.channels,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
Reference in New Issue
Block a user