/** * 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 trailPoints = []; 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; let gpsLocked = false; let heatLayer = null; let heatPoints = []; let movementStartMarker = null; let movementHeadMarker = null; let strongestMarker = null; let confidenceCircle = null; let heatmapEnabled = false; let movementEnabled = true; let autoFollowEnabled = true; let smoothingEnabled = true; let lastRenderedDetectionKey = null; let pendingHeatSync = false; let mapStabilizeTimer = null; let modeActive = false; let queuedDetection = null; let queuedDetectionOptions = null; let queuedDetectionTimer = null; let lastDetectionRenderAt = 0; let startRequestInFlight = false; let crosshairResetTimer = null; const MAX_HEAT_POINTS = 1200; const MAX_TRAIL_POINTS = 1200; const CONFIDENCE_WINDOW_POINTS = 8; const OUTLIER_HARD_JUMP_METERS = 2000; const OUTLIER_SOFT_JUMP_METERS = 450; const OUTLIER_MAX_SPEED_MPS = 50; const MAP_STABILIZE_INTERVAL_MS = 220; const MAP_STABILIZE_ATTEMPTS = 8; const MIN_DETECTION_RENDER_MS = 220; const OVERLAY_STORAGE_KEYS = { heatmap: 'btLocateHeatmapEnabled', movement: 'btLocateMovementEnabled', follow: 'btLocateFollowEnabled', smoothing: 'btLocateSmoothingEnabled', }; const HEAT_LAYER_OPTIONS = { radius: 26, blur: 20, minOpacity: 0.25, maxZoom: 19, gradient: { 0.15: '#2563eb', 0.45: '#16a34a', 0.75: '#f59e0b', 1.0: '#ef4444', }, }; const BT_LOCATE_DEBUG = (() => { try { const params = new URLSearchParams(window.location.search || ''); return params.get('btlocate_debug') === '1' || localStorage.getItem('btLocateDebug') === 'true'; } catch (_) { return false; } })(); function debugLog() { if (!BT_LOCATE_DEBUG) return; console.log.apply(console, arguments); } function getMapContainer() { if (!map || typeof map.getContainer !== 'function') return null; return map.getContainer(); } function isMapContainerVisible() { const container = getMapContainer(); if (!container) return false; if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false; if (container.style && container.style.display === 'none') return false; if (typeof window.getComputedStyle === 'function') { const style = window.getComputedStyle(container); if (style.display === 'none' || style.visibility === 'hidden') return false; } return true; } function statusUrl() { try { const params = new URLSearchParams(window.location.search || ''); const debugFlag = params.get('btlocate_debug') === '1' || localStorage.getItem('btLocateDebug') === 'true'; return debugFlag ? '/bt_locate/status?debug=1' : '/bt_locate/status'; } catch (_) { return '/bt_locate/status'; } } function coerceLocation(lat, lon) { const nLat = Number(lat); const nLon = Number(lon); if (!isFinite(nLat) || !isFinite(nLon)) return null; if (nLat < -90 || nLat > 90 || nLon < -180 || nLon > 180) return null; return { lat: nLat, lon: nLon }; } function resolveFallbackLocation() { try { if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) { const shared = ObserverLocation.getShared(); const normalized = coerceLocation(shared?.lat, shared?.lon); if (normalized) return normalized; } } catch (_) {} try { const stored = localStorage.getItem('observerLocation'); if (stored) { const parsed = JSON.parse(stored); const normalized = coerceLocation(parsed?.lat, parsed?.lon); if (normalized) return normalized; } } catch (_) {} try { const normalized = coerceLocation( localStorage.getItem('observerLat'), localStorage.getItem('observerLon') ); if (normalized) return normalized; } catch (_) {} return coerceLocation(window.INTERCEPT_DEFAULT_LAT, window.INTERCEPT_DEFAULT_LON); } function setStartButtonBusy(busy) { const startBtn = document.getElementById('btLocateStartBtn'); if (!startBtn) return; if (busy) { if (!startBtn.dataset.defaultLabel) { startBtn.dataset.defaultLabel = startBtn.textContent || 'Start Locate'; } startBtn.disabled = true; startBtn.textContent = 'Starting...'; return; } startBtn.disabled = false; startBtn.textContent = startBtn.dataset.defaultLabel || 'Start Locate'; } function init() { modeActive = true; loadOverlayPreferences(); syncOverlayControls(); if (initialized) { // Re-invalidate map on re-entry and ensure tiles are present if (map) { setTimeout(() => { safeInvalidateMap(); // 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); } flushPendingHeatSync(); scheduleMapStabilization(10); }, 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, }); let tileLayer = null; // Use tile provider from user settings if (typeof Settings !== 'undefined' && Settings.createTileLayer) { tileLayer = Settings.createTileLayer(); tileLayer.addTo(map); Settings.registerMap(map); } else { tileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19, attribution: '© OSM © CARTO' }); tileLayer.addTo(map); } if (tileLayer && typeof tileLayer.on === 'function') { tileLayer.on('load', () => { scheduleMapStabilization(8); }); } ensureHeatLayer(); syncMovementLayer(); syncHeatLayer(); map.on('resize moveend zoomend', () => { flushPendingHeatSync(); }); requestAnimationFrame(() => { safeInvalidateMap(); flushPendingHeatSync(); scheduleMapStabilization(); }); } // Init RSSI chart canvas chartCanvas = document.getElementById('btLocateRssiChart'); if (chartCanvas) { chartCtx = chartCanvas.getContext('2d'); } checkStatus(); initialized = true; } function checkStatus() { fetch(statusUrl()) .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(); restoreTrail(); } }) .catch(() => {}); } function normalizeMacInput(value) { const raw = (value || '').trim().toUpperCase().replace(/-/g, ':'); if (!raw) return ''; const compact = raw.replace(/[^0-9A-F]/g, ''); if (compact.length === 12) { return compact.match(/.{1,2}/g).join(':'); } return raw; } function start() { if (startRequestInFlight) { return; } const mac = normalizeMacInput(document.getElementById('btLocateMac')?.value); 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?.device_key) body.device_key = handoffData.device_key; if (handoffData?.fingerprint_id) body.fingerprint_id = handoffData.fingerprint_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 fallbackLocation = resolveFallbackLocation(); if (fallbackLocation) { body.fallback_lat = fallbackLocation.lat; body.fallback_lon = fallbackLocation.lon; } debugLog('[BtLocate] Starting with body:', body); if (!body.mac_address && !body.name_pattern && !body.irk_hex && !body.device_id && !body.device_key && !body.fingerprint_id) { alert('Please provide at least one target identifier or use hand-off from Bluetooth mode.'); return; } startRequestInFlight = true; setStartButtonBusy(true); fetch('/bt_locate/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) .then(async (r) => { let data = null; try { data = await r.json(); } catch (_) { data = {}; } if (!r.ok || data.status !== 'started') { const message = data.error || data.message || ('HTTP ' + r.status); throw new Error(message); } return data; }) .then(data => { if (data.status === 'started') { sessionStartedAt = data.session?.started_at ? new Date(data.session.started_at).getTime() : Date.now(); showActiveUI(); connectSSE(); rssiHistory = []; gpsLocked = false; lastRenderedDetectionKey = null; updateScanStatus(data.session); // Restore any existing trail (e.g. from a stop/start cycle) restoreTrail(); pollStatus(); } }) .catch(err => { console.error('[BtLocate] Start error:', err); alert('BT Locate failed to start: ' + (err?.message || 'Unknown error')); showIdleUI(); }) .finally(() => { startRequestInFlight = false; setStartButtonBusy(false); }); } function stop() { // Update UI immediately — don't wait for the backend response. if (queuedDetectionTimer) { clearTimeout(queuedDetectionTimer); queuedDetectionTimer = null; } queuedDetection = null; queuedDetectionOptions = null; showIdleUI(); disconnectSSE(); stopAudio(); // Notify backend asynchronously. fetch('/bt_locate/stop', { method: 'POST' }) .catch(err => console.error('[BtLocate] Stop error:', err)); } function showActiveUI() { setStartButtonBusy(false); 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() { startRequestInFlight = false; setStartButtonBusy(false); if (queuedDetectionTimer) { clearTimeout(queuedDetectionTimer); queuedDetectionTimer = null; } queuedDetection = null; queuedDetectionOptions = null; 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(); debugLog('[BtLocate] Connecting SSE stream'); eventSource = new EventSource('/bt_locate/stream'); eventSource.addEventListener('detection', function(e) { try { const event = JSON.parse(e.data); debugLog('[BtLocate] Detection event:', event); handleDetection(event); } catch (err) { console.error('[BtLocate] Parse error:', err); } }); eventSource.addEventListener('session_ended', function() { showIdleUI(); disconnectSSE(); }); eventSource.onerror = function() { debugLog('[BtLocate] SSE error, polling fallback active'); if (eventSource && eventSource.readyState === EventSource.CLOSED) { eventSource = null; } }; // Start polling fallback (catches data even if SSE fails) startPolling(); pollStatus(); } 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(statusUrl()) .then(r => r.json()) .then(data => { if (!data.active) { showIdleUI(); disconnectSSE(); return; } updateScanStatus(data); updateHudInfo(data); // Recover live stream if browser closed SSE connection. if (!eventSource || eventSource.readyState === EventSource.CLOSED) { connectSSE(); } // 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 }, { skipStatsIncrement: true }); } 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 || ''; const addrDisplay = formatAddr(addr); targetEl.textContent = name ? (name + (addrDisplay ? ' (' + addrDisplay + ')' : '')) : addrDisplay || '--'; } // 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 flushQueuedDetection() { if (!queuedDetection) return; const event = queuedDetection; const options = queuedDetectionOptions || {}; queuedDetection = null; queuedDetectionOptions = null; queuedDetectionTimer = null; renderDetection(event, options); } function handleDetection(event, options = {}) { if (!modeActive) { return; } const now = Date.now(); if (options.force || (now - lastDetectionRenderAt) >= MIN_DETECTION_RENDER_MS) { if (queuedDetectionTimer) { clearTimeout(queuedDetectionTimer); queuedDetectionTimer = null; } queuedDetection = null; queuedDetectionOptions = null; renderDetection(event, options); return; } // Keep only the freshest event while throttled. queuedDetection = event; queuedDetectionOptions = options; if (!queuedDetectionTimer) { queuedDetectionTimer = setTimeout(flushQueuedDetection, MIN_DETECTION_RENDER_MS); } } function renderDetection(event, options = {}) { lastDetectionRenderAt = Date.now(); const d = event?.data || event; if (!d) return; const detectionKey = buildDetectionKey(d); if (!options.allowDuplicate && detectionKey && detectionKey === lastRenderedDetectionKey) { return; } if (detectionKey) { lastRenderedDetectionKey = detectionKey; } updateDetectionHud(d); // RSSI sparkline if (typeof d.rssi === 'number' && isFinite(d.rssi)) { rssiHistory.push(d.rssi); if (rssiHistory.length > MAX_RSSI_POINTS) rssiHistory.shift(); drawRssiChart(); } // Map marker let mapPointAdded = false; if (d.lat != null && d.lon != null) { try { mapPointAdded = addMapMarker(d, { suppressFollow: options.suppressFollow === true }); } catch (error) { debugLog('[BtLocate] Map update skipped:', error); mapPointAdded = false; } } // Update stats if (!options.skipStatsIncrement) { const detCountEl = document.getElementById('btLocateDetectionCount'); const gpsCountEl = document.getElementById('btLocateGpsCount'); if (detCountEl) { const cur = parseInt(detCountEl.textContent) || 0; detCountEl.textContent = cur + 1; } if (gpsCountEl && mapPointAdded) { const cur = parseInt(gpsCountEl.textContent) || 0; gpsCountEl.textContent = cur + 1; } } // Audio if (audioEnabled) playProximityTone(d.rssi); } function updateDetectionHud(d) { 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 || '---'; const bandClass = (d.proximity_band || '').toLowerCase(); bandEl.className = bandClass ? 'btl-hud-band ' + bandClass : 'btl-hud-band'; } if (distEl) { if (typeof d.estimated_distance === 'number' && isFinite(d.estimated_distance)) { distEl.textContent = d.estimated_distance.toFixed(1); } else { distEl.textContent = '--'; } } if (rssiEl) rssiEl.textContent = d.rssi != null ? d.rssi : '--'; if (rssiEmaEl) { if (typeof d.rssi_ema === 'number' && isFinite(d.rssi_ema)) { rssiEmaEl.textContent = d.rssi_ema.toFixed(1); } else { rssiEmaEl.textContent = '--'; } } } 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 triggerCrosshairAnimation(lat, lon) { if (!map) return; const overlay = document.getElementById('btLocateCrosshairOverlay'); if (!overlay) return; const size = map.getSize(); const point = map.latLngToContainerPoint([lat, lon]); const targetX = Math.max(0, Math.min(size.x, point.x)); const targetY = Math.max(0, Math.min(size.y, point.y)); const startX = size.x + 8; const startY = size.y + 8; const duration = 1500; overlay.style.setProperty('--btl-crosshair-x-start', `${startX}px`); overlay.style.setProperty('--btl-crosshair-y-start', `${startY}px`); overlay.style.setProperty('--btl-crosshair-x-end', `${targetX}px`); overlay.style.setProperty('--btl-crosshair-y-end', `${targetY}px`); overlay.style.setProperty('--btl-crosshair-duration', `${duration}ms`); overlay.classList.remove('active'); void overlay.offsetWidth; overlay.classList.add('active'); if (crosshairResetTimer) clearTimeout(crosshairResetTimer); crosshairResetTimer = setTimeout(() => { overlay.classList.remove('active'); crosshairResetTimer = null; }, duration + 100); } function addMapMarker(point, options = {}) { if (!map || point.lat == null || point.lon == null) return false; const lat = Number(point.lat); const lon = Number(point.lon); if (!isFinite(lat) || !isFinite(lon)) return false; if (!shouldAcceptMapPoint(point, lat, lon)) return false; const suppressFollow = options.suppressFollow === true; const bulkLoad = options.bulkLoad === true; const trailPoint = normalizeTrailPoint(point, lat, lon); const band = (trailPoint.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([lat, lon], { radius: radius, fillColor: color, color: '#fff', weight: 1, opacity: 0.9, fillOpacity: 0.8, btLocateMeta: trailPoint, }).addTo(map); marker.bindPopup( '
' + '' + (trailPoint.proximity_band || 'Unknown') + '
' + 'RSSI: ' + (trailPoint.rssi != null ? trailPoint.rssi : '--') + ' dBm
' + 'Distance: ~' + formatDistanceForPopup(trailPoint.estimated_distance) + ' m
' + 'Time: ' + formatPointTimestamp(trailPoint.timestamp) + '
' ); marker.on('click', () => triggerCrosshairAnimation(lat, lon)); trailPoints.push(trailPoint); mapMarkers.push(marker); heatPoints.push([lat, lon, rssiToHeatWeight(trailPoint.rssi)]); while (trailPoints.length > MAX_TRAIL_POINTS) { trailPoints.shift(); const oldMarker = mapMarkers.shift(); if (oldMarker && map) map.removeLayer(oldMarker); } if (heatPoints.length > MAX_HEAT_POINTS) { heatPoints.splice(0, heatPoints.length - MAX_HEAT_POINTS); } if (bulkLoad) { pendingHeatSync = true; return true; } syncHeatLayer(); if (!isMapRenderable()) { safeInvalidateMap(); } const canFollowMap = isMapRenderable(); if (autoFollowEnabled && !suppressFollow && canFollowMap) { if (!gpsLocked) { gpsLocked = true; map.setView([lat, lon], Math.max(map.getZoom(), 16)); } else { map.panTo([lat, lon], { animate: true, duration: 0.35 }); } } else { gpsLocked = true; } syncMovementLayer(); syncStrongestMarker(); updateConfidenceLayer(); updateMovementStats(); return true; } function normalizeTrailPoint(point, lat, lon) { const rssiVal = Number(point.rssi); const rssiEmaVal = Number(point.rssi_ema); const distVal = Number(point.estimated_distance); return { lat: lat, lon: lon, rssi: isFinite(rssiVal) ? rssiVal : null, rssi_ema: isFinite(rssiEmaVal) ? rssiEmaVal : null, estimated_distance: isFinite(distVal) ? distVal : null, proximity_band: point.proximity_band || 'FAR', timestamp: point.timestamp || null, }; } function shouldAcceptMapPoint(point, lat, lon) { if (trailPoints.length === 0) return true; const prev = trailPoints[trailPoints.length - 1]; if (!prev) return true; const distanceMeters = map ? map.distance([prev.lat, prev.lon], [lat, lon]) : L.latLng(prev.lat, prev.lon).distanceTo(L.latLng(lat, lon)); if (!isFinite(distanceMeters)) return true; if (distanceMeters > OUTLIER_HARD_JUMP_METERS) return false; const prevTs = getTimestampMs(prev.timestamp); const currTs = getTimestampMs(point.timestamp); if (prevTs != null && currTs != null && currTs > prevTs) { const elapsedSec = (currTs - prevTs) / 1000; if (elapsedSec > 0) { const speedMps = distanceMeters / elapsedSec; if (distanceMeters > OUTLIER_SOFT_JUMP_METERS && speedMps > OUTLIER_MAX_SPEED_MPS) { return false; } } } else if (distanceMeters > OUTLIER_SOFT_JUMP_METERS) { return false; } return true; } function getTimestampMs(value) { if (!value) return null; const ts = new Date(value).getTime(); return isNaN(ts) ? null : ts; } function restoreTrail() { fetch('/bt_locate/trail') .then(r => r.json()) .then(trail => { clearMapMarkers(); const gpsTrail = Array.isArray(trail.gps_trail) ? trail.gps_trail : []; const allTrail = Array.isArray(trail.trail) ? trail.trail : []; const recentGpsTrail = gpsTrail.slice(-MAX_TRAIL_POINTS); recentGpsTrail.forEach(p => addMapMarker(p, { suppressFollow: true, bulkLoad: true, })); syncHeatLayer(); if (allTrail.length > 0) { rssiHistory = allTrail.map(p => p.rssi).filter(v => typeof v === 'number' && isFinite(v)).slice(-MAX_RSSI_POINTS); drawRssiChart(); const latest = allTrail[allTrail.length - 1]; updateDetectionHud(latest); lastRenderedDetectionKey = buildDetectionKey(latest); } else { rssiHistory = []; drawRssiChart(); } updateStats(allTrail.length, recentGpsTrail.length); if (trailPoints.length > 0 && map) { const latestGps = trailPoints[trailPoints.length - 1]; gpsLocked = true; const targetZoom = Math.max(map.getZoom(), 15); if (isMapRenderable()) { map.setView([latestGps.lat, latestGps.lon], targetZoom); } else { pendingHeatSync = true; } } syncMovementLayer(); syncStrongestMarker(); updateConfidenceLayer(); updateMovementStats(); scheduleMapStabilization(12); }) .catch(() => {}); } function clearMapMarkers() { mapMarkers.forEach(m => map?.removeLayer(m)); mapMarkers = []; trailPoints = []; heatPoints = []; if (trailLine) { map?.removeLayer(trailLine); trailLine = null; } if (movementStartMarker) { map?.removeLayer(movementStartMarker); movementStartMarker = null; } if (movementHeadMarker) { map?.removeLayer(movementHeadMarker); movementHeadMarker = null; } if (strongestMarker) { map?.removeLayer(strongestMarker); strongestMarker = null; } if (confidenceCircle) { map?.removeLayer(confidenceCircle); confidenceCircle = null; } if (heatLayer) { try { if (isMapRenderable()) { heatLayer.setLatLngs([]); } else { pendingHeatSync = true; } } catch (error) { pendingHeatSync = true; } } updateStrongestInfo(null); updateConfidenceInfo(null); updateMovementStats(); } function syncStrongestMarker() { if (!map) return; const strongest = getStrongestTrailPoint(); if (!strongest) { if (strongestMarker) { map.removeLayer(strongestMarker); strongestMarker = null; } updateStrongestInfo(null); return; } const latlng = [strongest.lat, strongest.lon]; if (!strongestMarker) { strongestMarker = L.circleMarker(latlng, { radius: 7, fillColor: '#f59e0b', color: '#ffffff', weight: 2, fillOpacity: 0.9, }).addTo(map).bindTooltip('Best RSSI', { direction: 'top' }); } else { strongestMarker.setLatLng(latlng); if (!map.hasLayer(strongestMarker)) { strongestMarker.addTo(map); } } strongestMarker.bindPopup( '
' + 'Strongest Signal
' + 'RSSI: ' + strongest.rssi + ' dBm
' + 'Time: ' + formatPointTimestamp(strongest.timestamp) + '
' ); updateStrongestInfo(strongest); } function getStrongestTrailPoint() { let best = null; for (const p of trailPoints) { if (typeof p.rssi !== 'number' || !isFinite(p.rssi)) continue; if (!best || p.rssi > best.rssi) { best = p; } } return best; } function updateStrongestInfo(strongest) { const strongestEl = document.getElementById('btLocateBestSignal'); if (!strongestEl) return; if (!strongest || typeof strongest.rssi !== 'number' || !isFinite(strongest.rssi)) { strongestEl.textContent = 'Best: --'; return; } strongestEl.textContent = 'Best: ' + strongest.rssi + ' dBm'; } function updateConfidenceLayer() { if (!map) return; const latest = trailPoints[trailPoints.length - 1]; const radius = computeConfidenceRadiusMeters(); if (!latest || radius == null) { if (confidenceCircle) { map.removeLayer(confidenceCircle); confidenceCircle = null; } updateConfidenceInfo(null); return; } if (!confidenceCircle) { confidenceCircle = L.circle([latest.lat, latest.lon], { radius: radius, color: '#93c5fd', weight: 1, fillColor: '#60a5fa', fillOpacity: 0.08, }).addTo(map); } else { confidenceCircle.setLatLng([latest.lat, latest.lon]); confidenceCircle.setRadius(radius); if (!map.hasLayer(confidenceCircle)) { confidenceCircle.addTo(map); } } updateConfidenceInfo(radius); } function computeConfidenceRadiusMeters() { if (trailPoints.length < 2) return null; const sample = trailPoints.slice(-CONFIDENCE_WINDOW_POINTS); const distances = sample.map(p => p.estimated_distance).filter(v => typeof v === 'number' && isFinite(v) && v > 0); const rssis = sample.map(p => p.rssi).filter(v => typeof v === 'number' && isFinite(v)); if (distances.length < 2 && rssis.length < 2) return null; const meanDistance = distances.length > 0 ? average(distances) : 20; const stdDistance = distances.length > 1 ? standardDeviation(distances) : 0; const stdRssi = rssis.length > 1 ? standardDeviation(rssis) : 0; const confidence = (meanDistance * 0.35) + (stdDistance * 1.6) + (stdRssi * 0.9) + 3; return Math.max(4, Math.min(150, confidence)); } function updateConfidenceInfo(radiusMeters) { const confidenceEl = document.getElementById('btLocateConfidenceInfo'); if (!confidenceEl) return; if (radiusMeters == null || !isFinite(radiusMeters)) { confidenceEl.textContent = 'Confidence: --'; return; } confidenceEl.textContent = 'Confidence: +/-' + Math.round(radiusMeters) + ' m'; } function buildDetectionKey(detection) { if (!detection) return ''; const timestamp = detection.timestamp || ''; const lat = detection.lat != null ? Number(detection.lat).toFixed(6) : ''; const lon = detection.lon != null ? Number(detection.lon).toFixed(6) : ''; const rssi = detection.rssi != null ? String(detection.rssi) : ''; return [timestamp, lat, lon, rssi].join('|'); } function rssiToHeatWeight(rssi) { const value = Number(rssi); if (!isFinite(value)) return 0.2; const min = -100; const max = -35; const clamped = Math.max(min, Math.min(max, value)); return 0.1 + ((clamped - min) / (max - min)) * 0.9; } function ensureHeatLayer() { if (!map || !heatmapEnabled || typeof L === 'undefined' || typeof L.heatLayer !== 'function') return; if (!heatLayer) { heatLayer = L.heatLayer([], HEAT_LAYER_OPTIONS); } } function syncHeatLayer() { if (!map) return; if (!heatmapEnabled) { if (heatLayer && map.hasLayer(heatLayer)) { map.removeLayer(heatLayer); } pendingHeatSync = false; return; } ensureHeatLayer(); if (!heatLayer) return; if (!modeActive || !isMapContainerVisible()) { if (map.hasLayer(heatLayer)) { map.removeLayer(heatLayer); } pendingHeatSync = true; return; } if (!isMapRenderable()) { safeInvalidateMap(); if (!isMapRenderable()) { pendingHeatSync = true; return; } } if (!Array.isArray(heatPoints) || heatPoints.length === 0) { if (map.hasLayer(heatLayer)) { map.removeLayer(heatLayer); } pendingHeatSync = false; return; } try { heatLayer.setLatLngs(heatPoints); if (heatmapEnabled) { if (!map.hasLayer(heatLayer)) { heatLayer.addTo(map); } } else if (map.hasLayer(heatLayer)) { map.removeLayer(heatLayer); } pendingHeatSync = false; } catch (error) { pendingHeatSync = true; if (map.hasLayer(heatLayer)) { map.removeLayer(heatLayer); } debugLog('[BtLocate] Heatmap redraw deferred:', error); } } function setActiveMode(active) { modeActive = !!active; if (!map) return; if (!modeActive) { stopMapStabilization(); if (queuedDetectionTimer) { clearTimeout(queuedDetectionTimer); queuedDetectionTimer = null; } queuedDetection = null; queuedDetectionOptions = null; // Pause BT Locate frontend work when mode is hidden. disconnectSSE(); if (heatLayer && map.hasLayer(heatLayer)) { map.removeLayer(heatLayer); } pendingHeatSync = true; return; } setTimeout(() => { if (!modeActive) return; safeInvalidateMap(); flushPendingHeatSync(); syncHeatLayer(); syncMovementLayer(); syncStrongestMarker(); updateConfidenceLayer(); scheduleMapStabilization(8); checkStatus(); }, 80); // A second pass after layout settles (sidebar/visual transitions). setTimeout(() => { if (!modeActive) return; safeInvalidateMap(); flushPendingHeatSync(); syncHeatLayer(); }, 260); } function isMapRenderable() { if (!map || !isMapContainerVisible()) return false; if (typeof map.getSize === 'function') { const size = map.getSize(); if (!size || size.x <= 0 || size.y <= 0) return false; } return true; } function safeInvalidateMap() { if (!map || !isMapContainerVisible()) return false; map.invalidateSize({ pan: false, animate: false }); return true; } function stopMapStabilization() { if (mapStabilizeTimer) { clearInterval(mapStabilizeTimer); mapStabilizeTimer = null; } } function scheduleMapStabilization(attempts = MAP_STABILIZE_ATTEMPTS) { if (!map) return; stopMapStabilization(); let remaining = Math.max(1, Number(attempts) || MAP_STABILIZE_ATTEMPTS); const tick = () => { if (!map) { stopMapStabilization(); return; } if (safeInvalidateMap()) { flushPendingHeatSync(); syncMovementLayer(); syncStrongestMarker(); updateConfidenceLayer(); if (isMapRenderable()) { stopMapStabilization(); return; } } remaining -= 1; if (remaining <= 0) { stopMapStabilization(); } }; tick(); if (map && !mapStabilizeTimer && !isMapRenderable()) { mapStabilizeTimer = setInterval(tick, MAP_STABILIZE_INTERVAL_MS); } } function flushPendingHeatSync() { if (!pendingHeatSync) return; syncHeatLayer(); } function syncMovementLayer() { if (!map) return; const rawLatlngs = trailPoints.map(p => L.latLng(p.lat, p.lon)); const latlngs = smoothingEnabled ? smoothLatLngs(rawLatlngs) : rawLatlngs; if (!movementEnabled || latlngs.length < 2) { if (trailLine) { map.removeLayer(trailLine); trailLine = null; } } else if (!trailLine) { trailLine = L.polyline(latlngs, { color: '#00ff88', weight: 3, opacity: 0.65, smoothFactor: smoothingEnabled ? 1.0 : 0.2, }).addTo(map); } else { trailLine.setLatLngs(latlngs); trailLine.options.smoothFactor = smoothingEnabled ? 1.0 : 0.2; if (!map.hasLayer(trailLine)) { trailLine.addTo(map); } } if (!movementEnabled || latlngs.length === 0) { if (movementStartMarker) { map.removeLayer(movementStartMarker); movementStartMarker = null; } if (movementHeadMarker) { map.removeLayer(movementHeadMarker); movementHeadMarker = null; } return; } const start = rawLatlngs[0]; const latest = rawLatlngs[rawLatlngs.length - 1]; if (!movementStartMarker) { movementStartMarker = L.circleMarker(start, { radius: 4, fillColor: '#38bdf8', color: '#ffffff', weight: 1, fillOpacity: 0.9, }).addTo(map).bindTooltip('Start', { direction: 'top' }); } else { movementStartMarker.setLatLng(start); if (!map.hasLayer(movementStartMarker)) { movementStartMarker.addTo(map); } } if (!movementHeadMarker) { movementHeadMarker = L.circleMarker(latest, { radius: 6, fillColor: '#22c55e', color: '#ffffff', weight: 1, fillOpacity: 1, }).addTo(map).bindTooltip('Latest', { direction: 'top' }); } else { movementHeadMarker.setLatLng(latest); if (!map.hasLayer(movementHeadMarker)) { movementHeadMarker.addTo(map); } } } function updateMovementStats() { const statsEl = document.getElementById('btLocateTrackStats'); if (!statsEl) return; const points = trailPoints.map(p => L.latLng(p.lat, p.lon)); if (points.length < 2) { statsEl.textContent = 'Track: 0 m | ' + points.length + ' pts'; return; } let totalMeters = 0; for (let i = 1; i < points.length; i++) { totalMeters += points[i - 1].distanceTo(points[i]); } let speedSuffix = ''; const firstMeta = trailPoints[0] || null; const lastMeta = trailPoints[points.length - 1] || null; if (firstMeta?.timestamp && lastMeta?.timestamp) { const elapsedSec = (new Date(lastMeta.timestamp).getTime() - new Date(firstMeta.timestamp).getTime()) / 1000; if (elapsedSec > 5 && isFinite(elapsedSec)) { const avgKmh = (totalMeters / elapsedSec) * 3.6; if (isFinite(avgKmh)) { speedSuffix = ' | avg ' + avgKmh.toFixed(avgKmh < 10 ? 1 : 0) + ' km/h'; } } } statsEl.textContent = 'Track: ' + humanDistance(totalMeters) + ' | ' + points.length + ' pts' + speedSuffix; } function smoothLatLngs(latlngs) { if (!Array.isArray(latlngs) || latlngs.length < 3) return latlngs; const smoothed = []; for (let i = 0; i < latlngs.length; i++) { const start = Math.max(0, i - 1); const end = Math.min(latlngs.length - 1, i + 1); let latSum = 0; let lngSum = 0; let count = 0; for (let j = start; j <= end; j++) { latSum += latlngs[j].lat; lngSum += latlngs[j].lng; count += 1; } smoothed.push(L.latLng(latSum / count, lngSum / count)); } return smoothed; } function humanDistance(meters) { if (!isFinite(meters) || meters <= 0) return '0 m'; if (meters >= 1000) { return (meters / 1000).toFixed(meters >= 10000 ? 1 : 2) + ' km'; } return Math.round(meters) + ' m'; } function formatDistanceForPopup(value) { const dist = Number(value); if (!isFinite(dist)) return '--'; return dist.toFixed(1); } function formatPointTimestamp(value) { if (!value) return '--'; const ts = new Date(value); if (isNaN(ts.getTime())) return '--'; return ts.toLocaleTimeString(); } function average(values) { if (!Array.isArray(values) || values.length === 0) return 0; return values.reduce((sum, val) => sum + val, 0) / values.length; } function standardDeviation(values) { if (!Array.isArray(values) || values.length < 2) return 0; const mean = average(values); const variance = values.reduce((sum, val) => { const delta = val - mean; return sum + (delta * delta); }, 0) / values.length; return Math.sqrt(variance); } function loadOverlayPreferences() { const heatmapPref = localStorage.getItem(OVERLAY_STORAGE_KEYS.heatmap); const movementPref = localStorage.getItem(OVERLAY_STORAGE_KEYS.movement); const followPref = localStorage.getItem(OVERLAY_STORAGE_KEYS.follow); const smoothingPref = localStorage.getItem(OVERLAY_STORAGE_KEYS.smoothing); if (heatmapPref !== null) heatmapEnabled = heatmapPref === 'true'; if (movementPref !== null) movementEnabled = movementPref === 'true'; if (followPref !== null) autoFollowEnabled = followPref === 'true'; if (smoothingPref !== null) smoothingEnabled = smoothingPref === 'true'; } function syncOverlayControls() { const heatmapCb = document.getElementById('btLocateHeatmapEnable'); const movementCb = document.getElementById('btLocateMovementEnable'); const followCb = document.getElementById('btLocateFollowEnable'); const smoothCb = document.getElementById('btLocateSmoothEnable'); const legend = document.getElementById('btLocateHeatLegend'); const heatAvailable = typeof L !== 'undefined' && typeof L.heatLayer === 'function'; if (heatmapCb) { heatmapCb.checked = heatAvailable ? heatmapEnabled : false; heatmapCb.disabled = !heatAvailable; } if (movementCb) movementCb.checked = movementEnabled; if (followCb) followCb.checked = autoFollowEnabled; if (smoothCb) smoothCb.checked = smoothingEnabled; if (legend) legend.style.display = heatmapEnabled && heatAvailable ? '' : 'none'; } function toggleHeatmap() { const cb = document.getElementById('btLocateHeatmapEnable'); heatmapEnabled = cb ? cb.checked : !heatmapEnabled; localStorage.setItem(OVERLAY_STORAGE_KEYS.heatmap, String(heatmapEnabled)); syncOverlayControls(); syncHeatLayer(); } function toggleMovement() { const cb = document.getElementById('btLocateMovementEnable'); movementEnabled = cb ? cb.checked : !movementEnabled; localStorage.setItem(OVERLAY_STORAGE_KEYS.movement, String(movementEnabled)); syncOverlayControls(); syncMovementLayer(); updateMovementStats(); } function toggleFollow() { const cb = document.getElementById('btLocateFollowEnable'); autoFollowEnabled = cb ? cb.checked : !autoFollowEnabled; localStorage.setItem(OVERLAY_STORAGE_KEYS.follow, String(autoFollowEnabled)); syncOverlayControls(); } function toggleSmoothing() { const cb = document.getElementById('btLocateSmoothEnable'); smoothingEnabled = cb ? cb.checked : !smoothingEnabled; localStorage.setItem(OVERLAY_STORAGE_KEYS.smoothing, String(smoothingEnabled)); syncOverlayControls(); syncMovementLayer(); } function exportTrail(format) { const formatSel = document.getElementById('btLocateExportFormat'); const exportFormat = String(format || formatSel?.value || 'csv').toLowerCase(); fetch('/bt_locate/trail') .then(r => r.json()) .then(data => { const allTrail = Array.isArray(data.trail) ? data.trail : []; if (allTrail.length === 0) { notifyExport('No data', 'No trail data to export yet.'); return; } let payload = ''; let mime = 'text/plain;charset=utf-8'; let ext = exportFormat; if (exportFormat === 'csv') { payload = buildTrailCsv(allTrail); mime = 'text/csv;charset=utf-8'; ext = 'csv'; } else if (exportFormat === 'gpx') { payload = buildTrailGpx(allTrail); mime = 'application/gpx+xml;charset=utf-8'; ext = 'gpx'; } else if (exportFormat === 'kml') { payload = buildTrailKml(allTrail); mime = 'application/vnd.google-earth.kml+xml;charset=utf-8'; ext = 'kml'; } else { notifyExport('Export failed', 'Unsupported export format: ' + exportFormat); return; } const downloaded = downloadTrailFile('bt-locate-' + buildExportStamp() + '.' + ext, payload, mime); if (downloaded) { notifyExport('Export ready', 'Downloaded BT Locate trail as ' + ext.toUpperCase()); } }) .catch(err => { console.error('[BtLocate] Export failed:', err); notifyExport('Export failed', 'Could not export trail data.'); }); } function buildTrailCsv(trail) { const header = [ 'timestamp', 'lat', 'lon', 'rssi', 'rssi_ema', 'estimated_distance', 'proximity_band', ]; const rows = trail.map(p => [ csvEscape(p.timestamp || ''), csvEscape(p.lat), csvEscape(p.lon), csvEscape(p.rssi), csvEscape(p.rssi_ema), csvEscape(p.estimated_distance), csvEscape(p.proximity_band || ''), ].join(',')); return [header.join(','), ...rows].join('\n'); } function buildTrailGpx(trail) { const pts = trail.filter(p => p.lat != null && p.lon != null); if (pts.length === 0) return ''; const trkPts = pts.map(p => { const rssi = p.rssi != null ? '' + escapeXml(String(p.rssi)) + '' : ''; const isoTime = toIsoStringSafe(p.timestamp); const time = isoTime ? '' : ''; return ( '' + time + rssi + '' ); }).join(''); return ( '' + '' + 'BT Locate Trail' + trkPts + '' + '' ); } function buildTrailKml(trail) { const pts = trail.filter(p => p.lat != null && p.lon != null); if (pts.length === 0) return ''; const lineCoords = pts.map(p => Number(p.lon).toFixed(6) + ',' + Number(p.lat).toFixed(6) + ',0').join(' '); const pointPlacemarks = pts.map((p, idx) => { const label = 'Point ' + (idx + 1) + ' | RSSI ' + (p.rssi != null ? p.rssi : '--') + ' dBm'; const desc = 'Time: ' + (toIsoStringSafe(p.timestamp) || '--'); return ( '' + '' + escapeXml(label) + '' + '' + escapeXml(desc) + '' + '' + Number(p.lon).toFixed(6) + ',' + Number(p.lat).toFixed(6) + ',0' + '' ); }).join(''); return ( '' + '' + 'BT Locate Trail' + 'Trail1' + lineCoords + '' + pointPlacemarks + '' ); } function csvEscape(value) { if (value == null) return ''; const text = String(value); if (/[",\n]/.test(text)) { return '"' + text.replace(/"/g, '""') + '"'; } return text; } function escapeXml(value) { if (value == null) return ''; return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function toIsoStringSafe(value) { if (!value) return ''; const ts = new Date(value); if (isNaN(ts.getTime())) return ''; return ts.toISOString(); } function buildExportStamp() { const now = new Date(); const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, '0'); const d = String(now.getDate()).padStart(2, '0'); const hh = String(now.getHours()).padStart(2, '0'); const mm = String(now.getMinutes()).padStart(2, '0'); const ss = String(now.getSeconds()).padStart(2, '0'); return '' + y + m + d + '-' + hh + mm + ss; } function downloadTrailFile(filename, content, mimeType) { if (!content) { notifyExport('No data', 'No GPS points available for this export format.'); return false; } const blob = new Blob([content], { type: mimeType || 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); return true; } function notifyExport(title, message) { if (typeof showNotification === 'function') { showNotification(title, message); } else { debugLog('[BtLocate] ' + title + ': ' + message); } } 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(() => { debugLog('[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(statusUrl()).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 => { debugLog('[BtLocate] Environment updated:', res); }); } }).catch(() => {}); } function isUuid(addr) { return addr && /^[0-9A-F]{8}-[0-9A-F]{4}-/i.test(addr); } function formatAddr(addr) { if (!addr) return ''; if (isUuid(addr)) return addr.substring(0, 8) + '-...' + addr.slice(-4); return addr; } function handoff(deviceInfo) { debugLog('[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 || formatAddr(deviceInfo.mac_address) || 'Unknown'; if (metaEl) { const parts = []; if (deviceInfo.mac_address) parts.push(formatAddr(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 '); } // Auto-fill IRK if available from scanner if (deviceInfo.irk_hex) { const irkInput = document.getElementById('btLocateIrk'); if (irkInput) irkInput.value = deviceInfo.irk_hex; } // 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 = '
' + (dev.name || 'Unknown Device') + '
' + '
' + dev.address + ' \u00b7 ' + (dev.address_type || '') + '
'; 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 = []; gpsLocked = false; lastRenderedDetectionKey = null; drawRssiChart(); updateStats(0, 0); }) .catch(err => console.error('[BtLocate] Clear trail error:', err)); } function invalidateMap() { if (safeInvalidateMap()) { flushPendingHeatSync(); syncMovementLayer(); syncStrongestMarker(); updateConfidenceLayer(); } scheduleMapStabilization(8); } return { init, setActiveMode, start, stop, handoff, clearHandoff, setEnvironment, toggleAudio, toggleHeatmap, toggleMovement, toggleFollow, toggleSmoothing, exportTrail, clearTrail, handleDetection, invalidateMap, fetchPairedIrks, destroy, }; /** * Destroy — close SSE stream and clear all timers for clean mode switching. */ function destroy() { if (eventSource) { eventSource.close(); eventSource = null; } if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } if (durationTimer) { clearInterval(durationTimer); durationTimer = null; } if (mapStabilizeTimer) { clearInterval(mapStabilizeTimer); mapStabilizeTimer = null; } if (queuedDetectionTimer) { clearTimeout(queuedDetectionTimer); queuedDetectionTimer = null; } if (crosshairResetTimer) { clearTimeout(crosshairResetTimer); crosshairResetTimer = null; } if (beepTimer) { clearInterval(beepTimer); beepTimer = null; } } })(); window.BtLocate = BtLocate;