mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 07:10:00 -07:00
feat: Add BT Locate and GPS modes with IRK auto-detection
New modes: - BT Locate: SAR Bluetooth device location with GPS-tagged signal trail, RSSI-based proximity bands, audio alerts, and IRK auto-extraction from paired devices (macOS plist / Linux BlueZ) - GPS: Real-time position tracking with live map, speed, heading, altitude, satellite info, and track recording via gpsd Bug fixes: - Fix ABBA deadlock between session lock and aggregator lock in BT Locate - Fix bleak scan lifecycle tracking in BluetoothScanner (is_scanning property now cross-checks backend state) - Fix map tile persistence when switching modes - Use 15s max_age window for fresh detections in BT Locate poll loop Documentation: - Update README, FEATURES.md, USAGE.md, and GitHub Pages with new modes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -366,10 +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>`;
|
||||
if (device.seen_before) {
|
||||
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</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) {
|
||||
@@ -451,14 +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()
|
||||
: '--';
|
||||
|
||||
updateWatchlistButton(device);
|
||||
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');
|
||||
@@ -470,29 +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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -546,43 +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.getElementById('btDetailCopyBtn');
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -1130,11 +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 seenBefore = device.seen_before === true;
|
||||
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)
|
||||
@@ -1186,9 +1186,9 @@ const BluetoothMode = (function() {
|
||||
|
||||
// Build secondary info line
|
||||
let secondaryParts = [addr];
|
||||
if (mfr) secondaryParts.push(mfr);
|
||||
secondaryParts.push('Seen ' + seenCount + '×');
|
||||
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
||||
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>');
|
||||
@@ -1216,6 +1216,11 @@ const BluetoothMode = (function() {
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="bt-row-secondary">' + secondaryInfo + '</div>' +
|
||||
'<div class="bt-row-actions">' +
|
||||
'<button class="bt-locate-btn" data-locate-id="' + escapeHtml(device.device_id) + '" onclick="event.stopPropagation(); BluetoothMode.locateById(this.dataset.locateId)">' +
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>' +
|
||||
'Locate</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
@@ -1391,6 +1396,42 @@ const BluetoothMode = (function() {
|
||||
updateRadar();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand off a device to BT Locate mode by device_id lookup.
|
||||
*/
|
||||
function locateById(deviceId) {
|
||||
console.log('[BT] locateById called with:', deviceId);
|
||||
const device = devices.get(deviceId);
|
||||
if (!device) {
|
||||
console.warn('[BT] Device not found in map for id:', deviceId);
|
||||
return;
|
||||
}
|
||||
doLocateHandoff(device);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand off the currently selected device to BT Locate mode.
|
||||
*/
|
||||
function locateDevice() {
|
||||
if (!selectedDeviceId) return;
|
||||
const device = devices.get(selectedDeviceId);
|
||||
if (!device) return;
|
||||
doLocateHandoff(device);
|
||||
}
|
||||
|
||||
function doLocateHandoff(device) {
|
||||
console.log('[BT] doLocateHandoff, BtLocate defined:', typeof BtLocate !== 'undefined');
|
||||
if (typeof BtLocate !== 'undefined') {
|
||||
BtLocate.handoff({
|
||||
device_id: device.device_id,
|
||||
mac_address: device.address,
|
||||
known_name: device.name || null,
|
||||
known_manufacturer: device.manufacturer_name || null,
|
||||
last_known_rssi: device.rssi_current
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
@@ -1400,10 +1441,12 @@ const BluetoothMode = (function() {
|
||||
setBaseline,
|
||||
clearBaseline,
|
||||
exportData,
|
||||
selectDevice,
|
||||
clearSelection,
|
||||
copyAddress,
|
||||
toggleWatchlist,
|
||||
selectDevice,
|
||||
clearSelection,
|
||||
copyAddress,
|
||||
toggleWatchlist,
|
||||
locateDevice,
|
||||
locateById,
|
||||
|
||||
// Agent handling
|
||||
handleAgentChange,
|
||||
|
||||
732
static/js/modes/bt_locate.js
Normal file
732
static/js/modes/bt_locate.js
Normal file
@@ -0,0 +1,732 @@
|
||||
/**
|
||||
* BT Locate — Bluetooth SAR Device Location Mode
|
||||
* GPS-tagged signal trail mapping with proximity audio alerts.
|
||||
*/
|
||||
const BtLocate = (function() {
|
||||
'use strict';
|
||||
|
||||
let eventSource = null;
|
||||
let map = null;
|
||||
let mapMarkers = [];
|
||||
let trailLine = null;
|
||||
let rssiHistory = [];
|
||||
const MAX_RSSI_POINTS = 60;
|
||||
let chartCanvas = null;
|
||||
let chartCtx = null;
|
||||
let currentEnvironment = 'OUTDOOR';
|
||||
let audioCtx = null;
|
||||
let audioEnabled = false;
|
||||
let beepTimer = null;
|
||||
let initialized = false;
|
||||
let handoffData = null;
|
||||
let pollTimer = null;
|
||||
let durationTimer = null;
|
||||
let sessionStartedAt = null;
|
||||
let lastDetectionCount = 0;
|
||||
|
||||
function init() {
|
||||
if (initialized) {
|
||||
// Re-invalidate map on re-entry and ensure tiles are present
|
||||
if (map) {
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
// Re-apply user's tile layer if tiles were lost
|
||||
let hasTiles = false;
|
||||
map.eachLayer(layer => {
|
||||
if (layer instanceof L.TileLayer) hasTiles = true;
|
||||
});
|
||||
if (!hasTiles && typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
||||
Settings.createTileLayer().addTo(map);
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
checkStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Init map
|
||||
const mapEl = document.getElementById('btLocateMap');
|
||||
if (mapEl && typeof L !== 'undefined') {
|
||||
map = L.map('btLocateMap', {
|
||||
center: [0, 0],
|
||||
zoom: 2,
|
||||
zoomControl: true,
|
||||
});
|
||||
// Use tile provider from user settings
|
||||
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
||||
Settings.createTileLayer().addTo(map);
|
||||
Settings.registerMap(map);
|
||||
} else {
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OSM © CARTO'
|
||||
}).addTo(map);
|
||||
}
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
}
|
||||
|
||||
// Init RSSI chart canvas
|
||||
chartCanvas = document.getElementById('btLocateRssiChart');
|
||||
if (chartCanvas) {
|
||||
chartCtx = chartCanvas.getContext('2d');
|
||||
}
|
||||
|
||||
checkStatus();
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
function checkStatus() {
|
||||
fetch('/bt_locate/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.active) {
|
||||
sessionStartedAt = data.started_at ? new Date(data.started_at).getTime() : Date.now();
|
||||
showActiveUI();
|
||||
updateScanStatus(data);
|
||||
if (!eventSource) connectSSE();
|
||||
// Restore trail from server
|
||||
fetch('/bt_locate/trail')
|
||||
.then(r => r.json())
|
||||
.then(trail => {
|
||||
if (trail.gps_trail) {
|
||||
trail.gps_trail.forEach(p => addMapMarker(p));
|
||||
}
|
||||
updateStats(data.detection_count, data.gps_trail_count);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function start() {
|
||||
const mac = document.getElementById('btLocateMac')?.value.trim();
|
||||
const namePattern = document.getElementById('btLocateNamePattern')?.value.trim();
|
||||
const irk = document.getElementById('btLocateIrk')?.value.trim();
|
||||
|
||||
const body = { environment: currentEnvironment };
|
||||
if (mac) body.mac_address = mac;
|
||||
if (namePattern) body.name_pattern = namePattern;
|
||||
if (irk) body.irk_hex = irk;
|
||||
if (handoffData?.device_id) body.device_id = handoffData.device_id;
|
||||
if (handoffData?.known_name) body.known_name = handoffData.known_name;
|
||||
if (handoffData?.known_manufacturer) body.known_manufacturer = handoffData.known_manufacturer;
|
||||
if (handoffData?.last_known_rssi) body.last_known_rssi = handoffData.last_known_rssi;
|
||||
|
||||
// Include user location as fallback when GPS unavailable
|
||||
const userLat = localStorage.getItem('observerLat');
|
||||
const userLon = localStorage.getItem('observerLon');
|
||||
if (userLat && userLon) {
|
||||
body.fallback_lat = parseFloat(userLat);
|
||||
body.fallback_lon = parseFloat(userLon);
|
||||
}
|
||||
|
||||
console.log('[BtLocate] Starting with body:', body);
|
||||
|
||||
if (!body.mac_address && !body.name_pattern && !body.irk_hex && !body.device_id) {
|
||||
alert('Please provide at least a MAC address, name pattern, IRK, or use hand-off from Bluetooth mode.');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/bt_locate/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'started') {
|
||||
sessionStartedAt = data.session?.started_at ? new Date(data.session.started_at).getTime() : Date.now();
|
||||
showActiveUI();
|
||||
connectSSE();
|
||||
rssiHistory = [];
|
||||
updateScanStatus(data.session);
|
||||
// Restore any existing trail (e.g. from a stop/start cycle)
|
||||
restoreTrail();
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[BtLocate] Start error:', err));
|
||||
}
|
||||
|
||||
function stop() {
|
||||
fetch('/bt_locate/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
showIdleUI();
|
||||
disconnectSSE();
|
||||
stopAudio();
|
||||
})
|
||||
.catch(err => console.error('[BtLocate] Stop error:', err));
|
||||
}
|
||||
|
||||
function showActiveUI() {
|
||||
const startBtn = document.getElementById('btLocateStartBtn');
|
||||
const stopBtn = document.getElementById('btLocateStopBtn');
|
||||
if (startBtn) startBtn.style.display = 'none';
|
||||
if (stopBtn) stopBtn.style.display = 'inline-block';
|
||||
show('btLocateHud');
|
||||
}
|
||||
|
||||
function showIdleUI() {
|
||||
const startBtn = document.getElementById('btLocateStartBtn');
|
||||
const stopBtn = document.getElementById('btLocateStopBtn');
|
||||
if (startBtn) startBtn.style.display = 'inline-block';
|
||||
if (stopBtn) stopBtn.style.display = 'none';
|
||||
hide('btLocateHud');
|
||||
hide('btLocateScanStatus');
|
||||
}
|
||||
|
||||
function updateScanStatus(statusData) {
|
||||
const el = document.getElementById('btLocateScanStatus');
|
||||
const dot = document.getElementById('btLocateScanDot');
|
||||
const text = document.getElementById('btLocateScanText');
|
||||
if (!el) return;
|
||||
|
||||
el.style.display = '';
|
||||
if (statusData && statusData.scanner_running) {
|
||||
if (dot) dot.style.background = '#22c55e';
|
||||
if (text) text.textContent = 'BT scanner active';
|
||||
} else {
|
||||
if (dot) dot.style.background = '#f97316';
|
||||
if (text) text.textContent = 'BT scanner not running — waiting...';
|
||||
}
|
||||
}
|
||||
|
||||
function show(id) { const el = document.getElementById(id); if (el) el.style.display = ''; }
|
||||
function hide(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; }
|
||||
|
||||
function connectSSE() {
|
||||
if (eventSource) eventSource.close();
|
||||
console.log('[BtLocate] Connecting SSE stream');
|
||||
eventSource = new EventSource('/bt_locate/stream');
|
||||
|
||||
eventSource.addEventListener('detection', function(e) {
|
||||
try {
|
||||
const event = JSON.parse(e.data);
|
||||
console.log('[BtLocate] Detection event:', event);
|
||||
handleDetection(event);
|
||||
} catch (err) {
|
||||
console.error('[BtLocate] Parse error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('session_ended', function() {
|
||||
showIdleUI();
|
||||
disconnectSSE();
|
||||
});
|
||||
|
||||
eventSource.onerror = function() {
|
||||
console.warn('[BtLocate] SSE error, polling fallback active');
|
||||
};
|
||||
|
||||
// Start polling fallback (catches data even if SSE fails)
|
||||
startPolling();
|
||||
}
|
||||
|
||||
function disconnectSSE() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
lastDetectionCount = 0;
|
||||
pollTimer = setInterval(pollStatus, 3000);
|
||||
startDurationTimer();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
stopDurationTimer();
|
||||
}
|
||||
|
||||
function startDurationTimer() {
|
||||
stopDurationTimer();
|
||||
durationTimer = setInterval(updateDuration, 1000);
|
||||
}
|
||||
|
||||
function stopDurationTimer() {
|
||||
if (durationTimer) {
|
||||
clearInterval(durationTimer);
|
||||
durationTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateDuration() {
|
||||
if (!sessionStartedAt) return;
|
||||
const elapsed = Math.round((Date.now() - sessionStartedAt) / 1000);
|
||||
const mins = Math.floor(elapsed / 60);
|
||||
const secs = elapsed % 60;
|
||||
const timeEl = document.getElementById('btLocateSessionTime');
|
||||
if (timeEl) timeEl.textContent = mins + ':' + String(secs).padStart(2, '0');
|
||||
}
|
||||
|
||||
function pollStatus() {
|
||||
fetch('/bt_locate/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data.active) {
|
||||
showIdleUI();
|
||||
disconnectSSE();
|
||||
return;
|
||||
}
|
||||
|
||||
updateScanStatus(data);
|
||||
updateHudInfo(data);
|
||||
|
||||
// Show diagnostics
|
||||
const diagEl = document.getElementById('btLocateDiag');
|
||||
if (diagEl) {
|
||||
let diag = 'Polls: ' + (data.poll_count || 0) +
|
||||
(data.poll_thread_alive === false ? ' DEAD' : '') +
|
||||
' | Scan: ' + (data.scanner_running ? 'Y' : 'N') +
|
||||
' | Devices: ' + (data.scanner_device_count || 0) +
|
||||
' | Det: ' + (data.detection_count || 0);
|
||||
// Show debug device sample if no detections
|
||||
if (data.detection_count === 0 && data.debug_devices && data.debug_devices.length > 0) {
|
||||
const matched = data.debug_devices.filter(d => d.match);
|
||||
const sample = data.debug_devices.slice(0, 3).map(d =>
|
||||
(d.name || '?') + '|' + (d.id || '').substring(0, 12) + ':' + (d.match ? 'Y' : 'N')
|
||||
).join(', ');
|
||||
diag += ' | Match:' + matched.length + '/' + data.debug_devices.length + ' [' + sample + ']';
|
||||
}
|
||||
diagEl.textContent = diag;
|
||||
}
|
||||
|
||||
// If detection count increased, fetch new trail points
|
||||
if (data.detection_count > lastDetectionCount) {
|
||||
lastDetectionCount = data.detection_count;
|
||||
fetch('/bt_locate/trail')
|
||||
.then(r => r.json())
|
||||
.then(trail => {
|
||||
if (trail.trail && trail.trail.length > 0) {
|
||||
const latest = trail.trail[trail.trail.length - 1];
|
||||
handleDetection({ data: latest });
|
||||
}
|
||||
updateStats(data.detection_count, data.gps_trail_count);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function updateHudInfo(data) {
|
||||
// Target info
|
||||
const targetEl = document.getElementById('btLocateTargetInfo');
|
||||
if (targetEl && data.target) {
|
||||
const t = data.target;
|
||||
const name = t.known_name || t.name_pattern || '';
|
||||
const addr = t.mac_address || t.device_id || '';
|
||||
targetEl.textContent = name ? (name + (addr ? ' (' + addr.substring(0, 8) + '...)' : '')) : addr || '--';
|
||||
}
|
||||
|
||||
// Environment info
|
||||
const envEl = document.getElementById('btLocateEnvInfo');
|
||||
if (envEl) {
|
||||
const envNames = { FREE_SPACE: 'Open Field', OUTDOOR: 'Outdoor', INDOOR: 'Indoor', CUSTOM: 'Custom' };
|
||||
envEl.textContent = (envNames[data.environment] || data.environment) + ' n=' + (data.path_loss_exponent || '?');
|
||||
}
|
||||
|
||||
// GPS status
|
||||
const gpsEl = document.getElementById('btLocateGpsStatus');
|
||||
if (gpsEl) {
|
||||
const src = data.gps_source || 'none';
|
||||
if (src === 'live') gpsEl.textContent = 'GPS: Live';
|
||||
else if (src === 'manual') gpsEl.textContent = 'GPS: Manual';
|
||||
else gpsEl.textContent = 'GPS: None';
|
||||
}
|
||||
|
||||
// Last seen
|
||||
const lastEl = document.getElementById('btLocateLastSeen');
|
||||
if (lastEl) {
|
||||
if (data.last_detection) {
|
||||
const ago = Math.round((Date.now() - new Date(data.last_detection).getTime()) / 1000);
|
||||
lastEl.textContent = 'Last: ' + (ago < 60 ? ago + 's ago' : Math.floor(ago / 60) + 'm ago');
|
||||
} else {
|
||||
lastEl.textContent = 'Last: --';
|
||||
}
|
||||
}
|
||||
|
||||
// Session start time (duration handled by 1s timer)
|
||||
if (data.started_at && !sessionStartedAt) {
|
||||
sessionStartedAt = new Date(data.started_at).getTime();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDetection(event) {
|
||||
const d = event.data;
|
||||
if (!d) return;
|
||||
|
||||
// Update proximity UI
|
||||
const bandEl = document.getElementById('btLocateBand');
|
||||
const distEl = document.getElementById('btLocateDistance');
|
||||
const rssiEl = document.getElementById('btLocateRssi');
|
||||
const rssiEmaEl = document.getElementById('btLocateRssiEma');
|
||||
|
||||
if (bandEl) {
|
||||
bandEl.textContent = d.proximity_band;
|
||||
bandEl.className = 'btl-hud-band ' + d.proximity_band.toLowerCase();
|
||||
}
|
||||
if (distEl) distEl.textContent = d.estimated_distance.toFixed(1);
|
||||
if (rssiEl) rssiEl.textContent = d.rssi;
|
||||
if (rssiEmaEl) rssiEmaEl.textContent = d.rssi_ema.toFixed(1);
|
||||
|
||||
// RSSI sparkline
|
||||
rssiHistory.push(d.rssi);
|
||||
if (rssiHistory.length > MAX_RSSI_POINTS) rssiHistory.shift();
|
||||
drawRssiChart();
|
||||
|
||||
// Map marker
|
||||
if (d.lat != null && d.lon != null) {
|
||||
addMapMarker(d);
|
||||
}
|
||||
|
||||
// Update stats
|
||||
const detCountEl = document.getElementById('btLocateDetectionCount');
|
||||
const gpsCountEl = document.getElementById('btLocateGpsCount');
|
||||
if (detCountEl) {
|
||||
const cur = parseInt(detCountEl.textContent) || 0;
|
||||
detCountEl.textContent = cur + 1;
|
||||
}
|
||||
if (gpsCountEl && d.lat != null) {
|
||||
const cur = parseInt(gpsCountEl.textContent) || 0;
|
||||
gpsCountEl.textContent = cur + 1;
|
||||
}
|
||||
|
||||
// Audio
|
||||
if (audioEnabled) playProximityTone(d.rssi);
|
||||
}
|
||||
|
||||
function updateStats(detections, gpsPoints) {
|
||||
const detCountEl = document.getElementById('btLocateDetectionCount');
|
||||
const gpsCountEl = document.getElementById('btLocateGpsCount');
|
||||
if (detCountEl) detCountEl.textContent = detections || 0;
|
||||
if (gpsCountEl) gpsCountEl.textContent = gpsPoints || 0;
|
||||
}
|
||||
|
||||
function addMapMarker(point) {
|
||||
if (!map || point.lat == null || point.lon == null) return;
|
||||
|
||||
const band = (point.proximity_band || 'FAR').toLowerCase();
|
||||
const colors = { immediate: '#ef4444', near: '#f97316', far: '#eab308' };
|
||||
const sizes = { immediate: 8, near: 6, far: 5 };
|
||||
const color = colors[band] || '#eab308';
|
||||
const radius = sizes[band] || 5;
|
||||
|
||||
const marker = L.circleMarker([point.lat, point.lon], {
|
||||
radius: radius,
|
||||
fillColor: color,
|
||||
color: '#fff',
|
||||
weight: 1,
|
||||
opacity: 0.9,
|
||||
fillOpacity: 0.8,
|
||||
}).addTo(map);
|
||||
|
||||
marker.bindPopup(
|
||||
'<div style="font-family:monospace;font-size:11px;">' +
|
||||
'<b>' + point.proximity_band + '</b><br>' +
|
||||
'RSSI: ' + point.rssi + ' dBm<br>' +
|
||||
'Distance: ~' + point.estimated_distance.toFixed(1) + ' m<br>' +
|
||||
'Time: ' + new Date(point.timestamp).toLocaleTimeString() +
|
||||
'</div>'
|
||||
);
|
||||
|
||||
mapMarkers.push(marker);
|
||||
map.panTo([point.lat, point.lon]);
|
||||
|
||||
// Update trail line
|
||||
const latlngs = mapMarkers.map(m => m.getLatLng());
|
||||
if (trailLine) {
|
||||
trailLine.setLatLngs(latlngs);
|
||||
} else if (latlngs.length >= 2) {
|
||||
trailLine = L.polyline(latlngs, {
|
||||
color: 'rgba(0,255,136,0.5)',
|
||||
weight: 2,
|
||||
dashArray: '4 4',
|
||||
}).addTo(map);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreTrail() {
|
||||
fetch('/bt_locate/trail')
|
||||
.then(r => r.json())
|
||||
.then(trail => {
|
||||
if (trail.gps_trail && trail.gps_trail.length > 0) {
|
||||
clearMapMarkers();
|
||||
trail.gps_trail.forEach(p => addMapMarker(p));
|
||||
}
|
||||
if (trail.trail && trail.trail.length > 0) {
|
||||
// Restore RSSI history from trail
|
||||
rssiHistory = trail.trail.map(p => p.rssi).slice(-MAX_RSSI_POINTS);
|
||||
drawRssiChart();
|
||||
// Update HUD with latest detection
|
||||
const latest = trail.trail[trail.trail.length - 1];
|
||||
handleDetection({ data: latest });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function clearMapMarkers() {
|
||||
mapMarkers.forEach(m => map?.removeLayer(m));
|
||||
mapMarkers = [];
|
||||
if (trailLine) {
|
||||
map?.removeLayer(trailLine);
|
||||
trailLine = null;
|
||||
}
|
||||
}
|
||||
|
||||
function drawRssiChart() {
|
||||
if (!chartCtx || !chartCanvas) return;
|
||||
|
||||
const w = chartCanvas.width = chartCanvas.parentElement.clientWidth - 16;
|
||||
const h = chartCanvas.height = chartCanvas.parentElement.clientHeight - 24;
|
||||
chartCtx.clearRect(0, 0, w, h);
|
||||
|
||||
if (rssiHistory.length < 2) return;
|
||||
|
||||
// RSSI range: -100 to -20
|
||||
const minR = -100, maxR = -20;
|
||||
const range = maxR - minR;
|
||||
|
||||
// Grid lines
|
||||
chartCtx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||||
chartCtx.lineWidth = 1;
|
||||
[-30, -50, -70, -90].forEach(v => {
|
||||
const y = h - ((v - minR) / range) * h;
|
||||
chartCtx.beginPath();
|
||||
chartCtx.moveTo(0, y);
|
||||
chartCtx.lineTo(w, y);
|
||||
chartCtx.stroke();
|
||||
});
|
||||
|
||||
// Draw RSSI line
|
||||
const step = w / (MAX_RSSI_POINTS - 1);
|
||||
chartCtx.beginPath();
|
||||
chartCtx.strokeStyle = '#00ff88';
|
||||
chartCtx.lineWidth = 2;
|
||||
|
||||
rssiHistory.forEach((rssi, i) => {
|
||||
const x = i * step;
|
||||
const y = h - ((rssi - minR) / range) * h;
|
||||
if (i === 0) chartCtx.moveTo(x, y);
|
||||
else chartCtx.lineTo(x, y);
|
||||
});
|
||||
chartCtx.stroke();
|
||||
|
||||
// Fill under
|
||||
const lastIdx = rssiHistory.length - 1;
|
||||
chartCtx.lineTo(lastIdx * step, h);
|
||||
chartCtx.lineTo(0, h);
|
||||
chartCtx.closePath();
|
||||
chartCtx.fillStyle = 'rgba(0,255,136,0.08)';
|
||||
chartCtx.fill();
|
||||
}
|
||||
|
||||
// Audio proximity tone (Web Audio API)
|
||||
function playTone(freq, duration) {
|
||||
if (!audioCtx || audioCtx.state !== 'running') return;
|
||||
const osc = audioCtx.createOscillator();
|
||||
const gain = audioCtx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(audioCtx.destination);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'sine';
|
||||
gain.gain.value = 0.2;
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
|
||||
osc.start();
|
||||
osc.stop(audioCtx.currentTime + duration);
|
||||
}
|
||||
|
||||
function playProximityTone(rssi) {
|
||||
if (!audioCtx || audioCtx.state !== 'running') return;
|
||||
// Stronger signal = higher pitch and shorter beep
|
||||
const strength = Math.max(0, Math.min(1, (rssi + 100) / 70));
|
||||
const freq = 400 + strength * 800; // 400-1200 Hz
|
||||
const duration = 0.06 + (1 - strength) * 0.12;
|
||||
playTone(freq, duration);
|
||||
}
|
||||
|
||||
function toggleAudio() {
|
||||
const cb = document.getElementById('btLocateAudioEnable');
|
||||
audioEnabled = cb?.checked || false;
|
||||
if (audioEnabled) {
|
||||
// Create AudioContext on user gesture (required by browser policy)
|
||||
if (!audioCtx) {
|
||||
try {
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
} catch (e) {
|
||||
console.error('[BtLocate] AudioContext creation failed:', e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Resume must happen within a user gesture handler
|
||||
const ctx = audioCtx;
|
||||
ctx.resume().then(() => {
|
||||
console.log('[BtLocate] AudioContext state:', ctx.state);
|
||||
// Confirmation beep so user knows audio is working
|
||||
playTone(600, 0.08);
|
||||
});
|
||||
} else {
|
||||
stopAudio();
|
||||
}
|
||||
}
|
||||
|
||||
function stopAudio() {
|
||||
audioEnabled = false;
|
||||
const cb = document.getElementById('btLocateAudioEnable');
|
||||
if (cb) cb.checked = false;
|
||||
}
|
||||
|
||||
function setEnvironment(env) {
|
||||
currentEnvironment = env;
|
||||
document.querySelectorAll('.btl-env-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.env === env);
|
||||
});
|
||||
// Push to running session if active
|
||||
fetch('/bt_locate/status').then(r => r.json()).then(data => {
|
||||
if (data.active) {
|
||||
fetch('/bt_locate/environment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ environment: env }),
|
||||
}).then(r => r.json()).then(res => {
|
||||
console.log('[BtLocate] Environment updated:', res);
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function handoff(deviceInfo) {
|
||||
console.log('[BtLocate] Handoff received:', deviceInfo);
|
||||
handoffData = deviceInfo;
|
||||
|
||||
// Populate fields
|
||||
if (deviceInfo.mac_address) {
|
||||
const macInput = document.getElementById('btLocateMac');
|
||||
if (macInput) macInput.value = deviceInfo.mac_address;
|
||||
}
|
||||
|
||||
// Show handoff card
|
||||
const card = document.getElementById('btLocateHandoffCard');
|
||||
const nameEl = document.getElementById('btLocateHandoffName');
|
||||
const metaEl = document.getElementById('btLocateHandoffMeta');
|
||||
if (card) card.style.display = '';
|
||||
if (nameEl) nameEl.textContent = deviceInfo.known_name || deviceInfo.mac_address || 'Unknown';
|
||||
if (metaEl) {
|
||||
const parts = [];
|
||||
if (deviceInfo.mac_address) parts.push(deviceInfo.mac_address);
|
||||
if (deviceInfo.known_manufacturer) parts.push(deviceInfo.known_manufacturer);
|
||||
if (deviceInfo.last_known_rssi != null) parts.push(deviceInfo.last_known_rssi + ' dBm');
|
||||
metaEl.textContent = parts.join(' \u00b7 ');
|
||||
}
|
||||
|
||||
// Switch to bt_locate mode
|
||||
if (typeof switchMode === 'function') {
|
||||
switchMode('bt_locate');
|
||||
}
|
||||
}
|
||||
|
||||
function clearHandoff() {
|
||||
handoffData = null;
|
||||
const card = document.getElementById('btLocateHandoffCard');
|
||||
if (card) card.style.display = 'none';
|
||||
}
|
||||
|
||||
function fetchPairedIrks() {
|
||||
const picker = document.getElementById('btLocateIrkPicker');
|
||||
const status = document.getElementById('btLocateIrkPickerStatus');
|
||||
const list = document.getElementById('btLocateIrkPickerList');
|
||||
const btn = document.getElementById('btLocateDetectIrkBtn');
|
||||
if (!picker || !status || !list) return;
|
||||
|
||||
// Toggle off if already visible
|
||||
if (picker.style.display !== 'none') {
|
||||
picker.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
picker.style.display = '';
|
||||
list.innerHTML = '';
|
||||
status.textContent = 'Scanning paired devices...';
|
||||
status.style.display = '';
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
fetch('/bt_locate/paired_irks')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (btn) btn.disabled = false;
|
||||
const devices = data.devices || [];
|
||||
|
||||
if (devices.length === 0) {
|
||||
status.textContent = 'No paired devices with IRKs found';
|
||||
return;
|
||||
}
|
||||
|
||||
status.style.display = 'none';
|
||||
list.innerHTML = '';
|
||||
|
||||
devices.forEach(dev => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'btl-irk-picker-item';
|
||||
item.innerHTML =
|
||||
'<div class="btl-irk-picker-name">' + (dev.name || 'Unknown Device') + '</div>' +
|
||||
'<div class="btl-irk-picker-meta">' + dev.address + ' \u00b7 ' + (dev.address_type || '') + '</div>';
|
||||
item.addEventListener('click', function() {
|
||||
selectPairedIrk(dev);
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
if (btn) btn.disabled = false;
|
||||
console.error('[BtLocate] Failed to fetch paired IRKs:', err);
|
||||
status.textContent = 'Failed to read paired devices';
|
||||
});
|
||||
}
|
||||
|
||||
function selectPairedIrk(dev) {
|
||||
const irkInput = document.getElementById('btLocateIrk');
|
||||
const nameInput = document.getElementById('btLocateNamePattern');
|
||||
const picker = document.getElementById('btLocateIrkPicker');
|
||||
|
||||
if (irkInput) irkInput.value = dev.irk_hex;
|
||||
if (nameInput && dev.name && !nameInput.value) nameInput.value = dev.name;
|
||||
if (picker) picker.style.display = 'none';
|
||||
}
|
||||
|
||||
function clearTrail() {
|
||||
fetch('/bt_locate/clear_trail', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
clearMapMarkers();
|
||||
rssiHistory = [];
|
||||
drawRssiChart();
|
||||
updateStats(0, 0);
|
||||
})
|
||||
.catch(err => console.error('[BtLocate] Clear trail error:', err));
|
||||
}
|
||||
|
||||
function invalidateMap() {
|
||||
if (map) map.invalidateSize();
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
start,
|
||||
stop,
|
||||
handoff,
|
||||
clearHandoff,
|
||||
setEnvironment,
|
||||
toggleAudio,
|
||||
clearTrail,
|
||||
handleDetection,
|
||||
invalidateMap,
|
||||
fetchPairedIrks,
|
||||
};
|
||||
})();
|
||||
401
static/js/modes/gps.js
Normal file
401
static/js/modes/gps.js
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* GPS Mode
|
||||
* Live GPS data display with satellite sky view, signal strength bars,
|
||||
* position/velocity/DOP readout. Connects to gpsd via backend SSE stream.
|
||||
*/
|
||||
|
||||
const GPS = (function() {
|
||||
let eventSource = null;
|
||||
let connected = false;
|
||||
let lastPosition = null;
|
||||
let lastSky = null;
|
||||
|
||||
// Constellation color map
|
||||
const CONST_COLORS = {
|
||||
'GPS': '#00d4ff',
|
||||
'GLONASS': '#00ff88',
|
||||
'Galileo': '#ff8800',
|
||||
'BeiDou': '#ff4466',
|
||||
'SBAS': '#ffdd00',
|
||||
'QZSS': '#cc66ff',
|
||||
};
|
||||
|
||||
function init() {
|
||||
drawEmptySkyView();
|
||||
connect();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
fetch('/gps/auto-connect', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'connected') {
|
||||
connected = true;
|
||||
updateConnectionUI(true, data.has_fix);
|
||||
if (data.position) {
|
||||
lastPosition = data.position;
|
||||
updatePositionUI(data.position);
|
||||
}
|
||||
if (data.sky) {
|
||||
lastSky = data.sky;
|
||||
updateSkyUI(data.sky);
|
||||
}
|
||||
startStream();
|
||||
} else {
|
||||
connected = false;
|
||||
updateConnectionUI(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
connected = false;
|
||||
updateConnectionUI(false);
|
||||
});
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
fetch('/gps/stop', { method: 'POST' })
|
||||
.then(() => {
|
||||
connected = false;
|
||||
updateConnectionUI(false);
|
||||
});
|
||||
}
|
||||
|
||||
function startStream() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
eventSource = new EventSource('/gps/stream');
|
||||
eventSource.onmessage = function(e) {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'position') {
|
||||
lastPosition = data;
|
||||
updatePositionUI(data);
|
||||
updateConnectionUI(true, true);
|
||||
} else if (data.type === 'sky') {
|
||||
lastSky = data;
|
||||
updateSkyUI(data);
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
eventSource.onerror = function() {
|
||||
// Reconnect handled by browser automatically
|
||||
};
|
||||
}
|
||||
|
||||
// ========================
|
||||
// UI Updates
|
||||
// ========================
|
||||
|
||||
function updateConnectionUI(isConnected, hasFix) {
|
||||
const dot = document.getElementById('gpsStatusDot');
|
||||
const text = document.getElementById('gpsStatusText');
|
||||
const connectBtn = document.getElementById('gpsConnectBtn');
|
||||
const disconnectBtn = document.getElementById('gpsDisconnectBtn');
|
||||
const devicePath = document.getElementById('gpsDevicePath');
|
||||
|
||||
if (dot) {
|
||||
dot.className = 'gps-status-dot';
|
||||
if (isConnected && hasFix) dot.classList.add('connected');
|
||||
else if (isConnected) dot.classList.add('waiting');
|
||||
}
|
||||
if (text) {
|
||||
if (isConnected && hasFix) text.textContent = 'Connected (Fix)';
|
||||
else if (isConnected) text.textContent = 'Connected (No Fix)';
|
||||
else text.textContent = 'Disconnected';
|
||||
}
|
||||
if (connectBtn) connectBtn.style.display = isConnected ? 'none' : '';
|
||||
if (disconnectBtn) disconnectBtn.style.display = isConnected ? '' : 'none';
|
||||
if (devicePath) devicePath.textContent = isConnected ? 'gpsd://localhost:2947' : '';
|
||||
}
|
||||
|
||||
function updatePositionUI(pos) {
|
||||
// Sidebar fields
|
||||
setText('gpsLat', pos.latitude != null ? pos.latitude.toFixed(6) + '\u00b0' : '---');
|
||||
setText('gpsLon', pos.longitude != null ? pos.longitude.toFixed(6) + '\u00b0' : '---');
|
||||
setText('gpsAlt', pos.altitude != null ? pos.altitude.toFixed(1) + ' m' : '---');
|
||||
setText('gpsSpeed', pos.speed != null ? (pos.speed * 3.6).toFixed(1) + ' km/h' : '---');
|
||||
setText('gpsHeading', pos.heading != null ? pos.heading.toFixed(1) + '\u00b0' : '---');
|
||||
setText('gpsClimb', pos.climb != null ? pos.climb.toFixed(2) + ' m/s' : '---');
|
||||
|
||||
// Fix type
|
||||
const fixEl = document.getElementById('gpsFixType');
|
||||
if (fixEl) {
|
||||
const fq = pos.fix_quality;
|
||||
if (fq === 3) fixEl.innerHTML = '<span class="gps-fix-badge fix-3d">3D FIX</span>';
|
||||
else if (fq === 2) fixEl.innerHTML = '<span class="gps-fix-badge fix-2d">2D FIX</span>';
|
||||
else fixEl.innerHTML = '<span class="gps-fix-badge no-fix">NO FIX</span>';
|
||||
}
|
||||
|
||||
// Error estimates
|
||||
const eph = (pos.epx != null && pos.epy != null) ? Math.sqrt(pos.epx * pos.epx + pos.epy * pos.epy) : null;
|
||||
setText('gpsEph', eph != null ? eph.toFixed(1) + ' m' : '---');
|
||||
setText('gpsEpv', pos.epv != null ? pos.epv.toFixed(1) + ' m' : '---');
|
||||
setText('gpsEps', pos.eps != null ? pos.eps.toFixed(2) + ' m/s' : '---');
|
||||
|
||||
// GPS time
|
||||
if (pos.timestamp) {
|
||||
const t = new Date(pos.timestamp);
|
||||
setText('gpsTime', t.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'));
|
||||
}
|
||||
|
||||
// Visuals: position panel
|
||||
setText('gpsVisPosLat', pos.latitude != null ? pos.latitude.toFixed(6) + '\u00b0' : '---');
|
||||
setText('gpsVisPosLon', pos.longitude != null ? pos.longitude.toFixed(6) + '\u00b0' : '---');
|
||||
setText('gpsVisPosAlt', pos.altitude != null ? pos.altitude.toFixed(1) + ' m' : '---');
|
||||
setText('gpsVisPosSpeed', pos.speed != null ? (pos.speed * 3.6).toFixed(1) + ' km/h' : '---');
|
||||
setText('gpsVisPosHeading', pos.heading != null ? pos.heading.toFixed(1) + '\u00b0' : '---');
|
||||
setText('gpsVisPosClimb', pos.climb != null ? pos.climb.toFixed(2) + ' m/s' : '---');
|
||||
|
||||
// Visuals: fix badge
|
||||
const visFixEl = document.getElementById('gpsVisFixBadge');
|
||||
if (visFixEl) {
|
||||
const fq = pos.fix_quality;
|
||||
if (fq === 3) { visFixEl.textContent = '3D FIX'; visFixEl.className = 'gps-fix-badge fix-3d'; }
|
||||
else if (fq === 2) { visFixEl.textContent = '2D FIX'; visFixEl.className = 'gps-fix-badge fix-2d'; }
|
||||
else { visFixEl.textContent = 'NO FIX'; visFixEl.className = 'gps-fix-badge no-fix'; }
|
||||
}
|
||||
|
||||
// Visuals: GPS time
|
||||
if (pos.timestamp) {
|
||||
const t = new Date(pos.timestamp);
|
||||
setText('gpsVisTime', t.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'));
|
||||
}
|
||||
}
|
||||
|
||||
function updateSkyUI(sky) {
|
||||
// Sidebar sat counts
|
||||
setText('gpsSatUsed', sky.usat != null ? sky.usat : '-');
|
||||
setText('gpsSatTotal', sky.nsat != null ? sky.nsat : '-');
|
||||
|
||||
// DOP values
|
||||
setDop('gpsHdop', sky.hdop);
|
||||
setDop('gpsVdop', sky.vdop);
|
||||
setDop('gpsPdop', sky.pdop);
|
||||
setDop('gpsTdop', sky.tdop);
|
||||
setDop('gpsGdop', sky.gdop);
|
||||
|
||||
// Visuals
|
||||
drawSkyView(sky.satellites || []);
|
||||
drawSignalBars(sky.satellites || []);
|
||||
}
|
||||
|
||||
function setDop(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
if (val == null) { el.textContent = '---'; el.className = 'gps-info-value gps-mono'; return; }
|
||||
el.textContent = val.toFixed(1);
|
||||
let cls = 'gps-info-value gps-mono ';
|
||||
if (val <= 2) cls += 'gps-dop-good';
|
||||
else if (val <= 5) cls += 'gps-dop-moderate';
|
||||
else cls += 'gps-dop-poor';
|
||||
el.className = cls;
|
||||
}
|
||||
|
||||
function setText(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Sky View Polar Plot
|
||||
// ========================
|
||||
|
||||
function drawEmptySkyView() {
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
drawSkyViewBase(canvas);
|
||||
}
|
||||
|
||||
function drawSkyView(satellites) {
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const r = Math.min(cx, cy) - 24;
|
||||
|
||||
drawSkyViewBase(canvas);
|
||||
|
||||
// Plot satellites
|
||||
satellites.forEach(sat => {
|
||||
if (sat.elevation == null || sat.azimuth == null) return;
|
||||
|
||||
const elRad = (90 - sat.elevation) / 90;
|
||||
const azRad = (sat.azimuth - 90) * Math.PI / 180; // N = up
|
||||
const px = cx + r * elRad * Math.cos(azRad);
|
||||
const py = cy + r * elRad * Math.sin(azRad);
|
||||
|
||||
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
|
||||
const dotSize = sat.used ? 6 : 4;
|
||||
|
||||
// Draw dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
||||
if (sat.used) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// PRN label
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = '8px JetBrains Mono, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText(sat.prn, px, py - dotSize - 2);
|
||||
|
||||
// SNR value
|
||||
if (sat.snr != null) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.font = '7px JetBrains Mono, monospace';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function drawSkyViewBase(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const r = Math.min(cx, cy) - 24;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Background
|
||||
const bgStyle = getComputedStyle(document.documentElement).getPropertyValue('--bg-card').trim();
|
||||
ctx.fillStyle = bgStyle || '#0d1117';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Elevation rings (0, 30, 60, 90)
|
||||
ctx.strokeStyle = '#2a3040';
|
||||
ctx.lineWidth = 0.5;
|
||||
[90, 60, 30].forEach(el => {
|
||||
const gr = r * (1 - el / 90);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
// Label
|
||||
ctx.fillStyle = '#555';
|
||||
ctx.font = '9px JetBrains Mono, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
|
||||
});
|
||||
|
||||
// Horizon circle
|
||||
ctx.strokeStyle = '#3a4050';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Cardinal directions
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = 'bold 11px JetBrains Mono, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('N', cx, cy - r - 12);
|
||||
ctx.fillText('S', cx, cy + r + 12);
|
||||
ctx.fillText('E', cx + r + 12, cy);
|
||||
ctx.fillText('W', cx - r - 12, cy);
|
||||
|
||||
// Crosshairs
|
||||
ctx.strokeStyle = '#2a3040';
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy - r);
|
||||
ctx.lineTo(cx, cy + r);
|
||||
ctx.moveTo(cx - r, cy);
|
||||
ctx.lineTo(cx + r, cy);
|
||||
ctx.stroke();
|
||||
|
||||
// Zenith dot
|
||||
ctx.fillStyle = '#333';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Signal Strength Bars
|
||||
// ========================
|
||||
|
||||
function drawSignalBars(satellites) {
|
||||
const container = document.getElementById('gpsSignalBars');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
if (satellites.length === 0) return;
|
||||
|
||||
// Sort: used first, then by PRN
|
||||
const sorted = [...satellites].sort((a, b) => {
|
||||
if (a.used !== b.used) return a.used ? -1 : 1;
|
||||
return a.prn - b.prn;
|
||||
});
|
||||
|
||||
const maxSnr = 50; // dB-Hz typical max for display
|
||||
|
||||
sorted.forEach(sat => {
|
||||
const snr = sat.snr || 0;
|
||||
const heightPct = Math.min(snr / maxSnr * 100, 100);
|
||||
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
|
||||
const constClass = 'gps-const-' + (sat.constellation || 'GPS').toLowerCase();
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'gps-signal-bar-wrap';
|
||||
|
||||
const snrLabel = document.createElement('span');
|
||||
snrLabel.className = 'gps-signal-snr';
|
||||
snrLabel.textContent = snr > 0 ? Math.round(snr) : '';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'gps-signal-bar ' + constClass + (sat.used ? '' : ' unused');
|
||||
bar.style.height = Math.max(heightPct, 2) + '%';
|
||||
bar.title = `PRN ${sat.prn} (${sat.constellation}) - ${Math.round(snr)} dB-Hz${sat.used ? ' [USED]' : ''}`;
|
||||
|
||||
const prn = document.createElement('span');
|
||||
prn.className = 'gps-signal-prn';
|
||||
prn.textContent = sat.prn;
|
||||
|
||||
wrap.appendChild(snrLabel);
|
||||
wrap.appendChild(bar);
|
||||
wrap.appendChild(prn);
|
||||
container.appendChild(wrap);
|
||||
});
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Cleanup
|
||||
// ========================
|
||||
|
||||
function destroy() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init: init,
|
||||
connect: connect,
|
||||
disconnect: disconnect,
|
||||
destroy: destroy,
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user