diff --git a/routes/bt_locate.py b/routes/bt_locate.py index 11f1aa3..85bab64 100644 --- a/routes/bt_locate.py +++ b/routes/bt_locate.py @@ -109,14 +109,27 @@ def start_session(): f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})" ) - session = start_locate_session( - target, environment, custom_exponent, fallback_lat, fallback_lon - ) - - return jsonify({ - 'status': 'started', - 'session': session.get_status(), - }) + try: + session = start_locate_session( + target, environment, custom_exponent, fallback_lat, fallback_lon + ) + except RuntimeError as exc: + logger.warning(f"Unable to start BT Locate session: {exc}") + return jsonify({ + 'status': 'error', + 'error': 'Bluetooth scanner could not be started. Check adapter permissions/capabilities.', + }), 503 + except Exception as exc: + logger.exception(f"Unexpected error starting BT Locate session: {exc}") + return jsonify({ + 'status': 'error', + 'error': 'Failed to start locate session', + }), 500 + + return jsonify({ + 'status': 'started', + 'session': session.get_status(), + }) @bt_locate_bp.route('/stop', methods=['POST']) diff --git a/routes/satellite.py b/routes/satellite.py index 9134b54..f9fb3ca 100644 --- a/routes/satellite.py +++ b/routes/satellite.py @@ -584,40 +584,67 @@ def list_tracked_satellites(): return jsonify({'status': 'success', 'satellites': sats}) -@satellite_bp.route('/tracked', methods=['POST']) -def add_tracked_satellites_endpoint(): - """Add one or more tracked satellites.""" - global _tle_cache - data = request.json - if not data: - return jsonify({'status': 'error', 'message': 'No data provided'}), 400 - - # Accept a single satellite dict or a list - sat_list = data if isinstance(data, list) else [data] - - added = 0 - for sat in sat_list: - norad_id = str(sat.get('norad_id', sat.get('norad', ''))) - name = sat.get('name', '') - if not norad_id or not name: - continue +@satellite_bp.route('/tracked', methods=['POST']) +def add_tracked_satellites_endpoint(): + """Add one or more tracked satellites.""" + global _tle_cache + data = request.get_json(silent=True) + if not data: + return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + + # Accept a single satellite dict or a list + sat_list = data if isinstance(data, list) else [data] + + normalized: list[dict] = [] + for sat in sat_list: + norad_id = str(sat.get('norad_id', sat.get('norad', ''))) + name = sat.get('name', '') + if not norad_id or not name: + continue tle1 = sat.get('tle_line1', sat.get('tle1')) tle2 = sat.get('tle_line2', sat.get('tle2')) enabled = sat.get('enabled', True) - if add_tracked_satellite(norad_id, name, tle1, tle2, enabled): - added += 1 - - # Also inject into TLE cache if we have TLE data - if tle1 and tle2: - cache_key = name.replace(' ', '-').upper() - _tle_cache[cache_key] = (name, tle1, tle2) - - return jsonify({ - 'status': 'success', - 'added': added, - 'satellites': get_tracked_satellites(), - }) + normalized.append({ + 'norad_id': norad_id, + 'name': name, + 'tle_line1': tle1, + 'tle_line2': tle2, + 'enabled': bool(enabled), + 'builtin': False, + }) + + # Also inject into TLE cache if we have TLE data + if tle1 and tle2: + cache_key = name.replace(' ', '-').upper() + _tle_cache[cache_key] = (name, tle1, tle2) + + # Single inserts preserve previous behavior; list inserts use DB-level bulk path. + if len(normalized) == 1: + sat = normalized[0] + added = 1 if add_tracked_satellite( + sat['norad_id'], + sat['name'], + sat.get('tle_line1'), + sat.get('tle_line2'), + sat.get('enabled', True), + sat.get('builtin', False), + ) else 0 + else: + added = bulk_add_tracked_satellites(normalized) + + response_payload = { + 'status': 'success', + 'added': added, + 'processed': len(normalized), + } + + # Returning all tracked satellites for very large imports can stall the UI. + include_satellites = request.args.get('include_satellites', '').lower() == 'true' + if include_satellites or len(normalized) <= 32: + response_payload['satellites'] = get_tracked_satellites() + + return jsonify(response_payload) @satellite_bp.route('/tracked/', methods=['PUT']) diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js index eb8a802..2df6e7e 100644 --- a/static/js/modes/bt_locate.js +++ b/static/js/modes/bt_locate.js @@ -38,6 +38,7 @@ const BtLocate = (function() { let lastRenderedDetectionKey = null; let pendingHeatSync = false; let mapStabilizeTimer = null; + let modeActive = false; const MAX_HEAT_POINTS = 1200; const MAX_TRAIL_POINTS = 1200; @@ -85,6 +86,7 @@ const BtLocate = (function() { } function init() { + modeActive = true; loadOverlayPreferences(); syncOverlayControls(); @@ -92,7 +94,7 @@ const BtLocate = (function() { // Re-invalidate map on re-entry and ensure tiles are present if (map) { setTimeout(() => { - safeInvalidateMap(); + safeInvalidateMap(true); // Re-apply user's tile layer if tiles were lost let hasTiles = false; map.eachLayer(layer => { @@ -142,7 +144,7 @@ const BtLocate = (function() { flushPendingHeatSync(); }); setTimeout(() => { - safeInvalidateMap(); + safeInvalidateMap(true); flushPendingHeatSync(); }, 100); scheduleMapStabilization(); @@ -158,10 +160,10 @@ const BtLocate = (function() { initialized = true; } - function checkStatus() { - fetch('/bt_locate/status') - .then(r => r.json()) - .then(data => { + 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(); @@ -171,12 +173,22 @@ const BtLocate = (function() { } }) .catch(() => {}); - } - - function start() { - const mac = document.getElementById('btLocateMac')?.value.trim(); - const namePattern = document.getElementById('btLocateNamePattern')?.value.trim(); - const irk = document.getElementById('btLocateIrk')?.value.trim(); + } + + 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() { + 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; @@ -205,13 +217,25 @@ const BtLocate = (function() { return; } - fetch('/bt_locate/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - .then(r => r.json()) - .then(data => { + 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(); @@ -224,8 +248,12 @@ const BtLocate = (function() { restoreTrail(); } }) - .catch(err => console.error('[BtLocate] Start error:', err)); - } + .catch(err => { + console.error('[BtLocate] Start error:', err); + alert('BT Locate failed to start: ' + (err?.message || 'Unknown error')); + showIdleUI(); + }); + } function stop() { fetch('/bt_locate/stop', { method: 'POST' }) @@ -888,7 +916,10 @@ const BtLocate = (function() { if (!map) return; ensureHeatLayer(); if (!heatLayer) return; - if (!isMapContainerVisible()) { + if (!modeActive || !isMapContainerVisible()) { + if (map.hasLayer(heatLayer)) { + map.removeLayer(heatLayer); + } pendingHeatSync = true; return; } @@ -918,6 +949,40 @@ const BtLocate = (function() { } } + function setActiveMode(active) { + modeActive = !!active; + if (!map) return; + + if (!modeActive) { + stopMapStabilization(); + if (heatLayer && map.hasLayer(heatLayer)) { + map.removeLayer(heatLayer); + } + pendingHeatSync = true; + return; + } + + setTimeout(() => { + if (!modeActive) return; + safeInvalidateMap(true); + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(() => { + if (!modeActive) return; + safeInvalidateMap(true); + window.requestAnimationFrame(() => { + if (!modeActive) return; + safeInvalidateMap(true); + }); + }); + } + syncHeatLayer(); + syncMovementLayer(); + syncStrongestMarker(); + updateConfidenceLayer(); + scheduleMapStabilization(14); + }, 80); + } + function isMapRenderable() { if (!map || !isMapContainerVisible()) return false; if (typeof map.getSize === 'function') { @@ -927,9 +992,26 @@ const BtLocate = (function() { return true; } - function safeInvalidateMap() { + function refreshBaseTiles() { + if (!map || typeof L === 'undefined' || typeof map.eachLayer !== 'function') return; + map.eachLayer((layer) => { + if (layer instanceof L.TileLayer && typeof layer.redraw === 'function') { + try { + layer.redraw(); + } catch (_) {} + } + }); + } + + function safeInvalidateMap(forceRecenter = false) { if (!map || !isMapContainerVisible()) return false; - map.invalidateSize({ pan: false, animate: false }); + map.invalidateSize({ pan: !!forceRecenter, animate: false }); + if (forceRecenter) { + const center = map.getCenter(); + const zoom = map.getZoom(); + map.setView(center, zoom, { animate: false }); + } + refreshBaseTiles(); return true; } @@ -950,7 +1032,7 @@ const BtLocate = (function() { stopMapStabilization(); return; } - if (safeInvalidateMap()) { + if (safeInvalidateMap(true)) { flushPendingHeatSync(); syncMovementLayer(); syncStrongestMarker(); @@ -1624,7 +1706,7 @@ const BtLocate = (function() { } function invalidateMap() { - if (safeInvalidateMap()) { + if (safeInvalidateMap(true)) { flushPendingHeatSync(); syncMovementLayer(); syncStrongestMarker(); @@ -1633,10 +1715,11 @@ const BtLocate = (function() { scheduleMapStabilization(8); } - return { - init, - start, - stop, + return { + init, + setActiveMode, + start, + stop, handoff, clearHandoff, setEnvironment, @@ -1651,4 +1734,6 @@ const BtLocate = (function() { invalidateMap, fetchPairedIrks, }; -})(); +})(); + +window.BtLocate = BtLocate; diff --git a/templates/index.html b/templates/index.html index ade06cc..a72830c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4004,6 +4004,11 @@ if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none'; if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none'; + // Prevent Leaflet heatmap redraws on hidden BT Locate map containers. + if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) { + BtLocate.setActiveMode(mode === 'bt_locate'); + } + // Hide sidebar by default for Meshtastic mode, show for others const mainContent = document.querySelector('.main-content'); if (mainContent) { @@ -10403,7 +10408,7 @@ fetch('/satellite/celestrak/' + category) .then(r => r.json()) - .then(data => { + .then(async data => { if (data.status === 'success' && data.satellites) { const toAdd = data.satellites .filter(sat => !trackedSatellites.find(s => s.norad === String(sat.norad))) @@ -10420,27 +10425,36 @@ return; } - fetch('/satellite/tracked', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(toAdd) - }) - .then(r => r.json()) - .then(result => { - if (result.status === 'success') { - _loadSatellitesFromAPI(); - status.innerHTML = `Added ${result.added} satellites (${data.satellites.length} total in category)`; + const batchSize = 250; + let addedTotal = 0; + + for (let i = 0; i < toAdd.length; i += batchSize) { + const batch = toAdd.slice(i, i + batchSize); + const completed = Math.min(i + batch.length, toAdd.length); + status.innerHTML = `Importing ${completed}/${toAdd.length} from ${category}...`; + + const resp = await fetch('/satellite/tracked', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(batch) + }); + const result = await resp.json().catch(() => ({})); + + if (!resp.ok || result.status !== 'success') { + throw new Error(result.message || result.error || `HTTP ${resp.status}`); } - }) - .catch(() => { - status.innerHTML = `Failed to save satellites`; - }); + addedTotal += Number(result.added || 0); + } + + _loadSatellitesFromAPI(); + status.innerHTML = `Added ${addedTotal} satellites (${data.satellites.length} total in category)`; } else { status.innerHTML = `Error: ${data.message || 'Failed to fetch'}`; } }) - .catch(() => { - status.innerHTML = `Network error`; + .catch((err) => { + const msg = err && err.message ? err.message : 'Network error'; + status.innerHTML = `Import failed: ${msg}`; }); } diff --git a/tests/test_bt_locate.py b/tests/test_bt_locate.py index 2f8313b..12a4eee 100644 --- a/tests/test_bt_locate.py +++ b/tests/test_bt_locate.py @@ -128,13 +128,21 @@ class TestLocateTarget: device.name = None assert target.matches(device) is True - def test_match_by_mac_case_insensitive(self): - target = LocateTarget(mac_address='aa:bb:cc:dd:ee:ff') - device = MagicMock() - device.device_id = 'other' - device.address = 'AA:BB:CC:DD:EE:FF' - device.name = None - assert target.matches(device) is True + def test_match_by_mac_case_insensitive(self): + target = LocateTarget(mac_address='aa:bb:cc:dd:ee:ff') + device = MagicMock() + device.device_id = 'other' + device.address = 'AA:BB:CC:DD:EE:FF' + device.name = None + assert target.matches(device) is True + + def test_match_by_mac_without_separators(self): + target = LocateTarget(mac_address='aabbccddeeff') + device = MagicMock() + device.device_id = 'other' + device.address = 'AA:BB:CC:DD:EE:FF' + device.name = None + assert target.matches(device) is True def test_match_by_name_pattern(self): target = LocateTarget(name_pattern='iPhone') @@ -243,7 +251,7 @@ class TestLocateSession: assert status['detection_count'] == 0 -class TestModuleLevelSessionManagement: +class TestModuleLevelSessionManagement: """Test module-level session functions.""" @patch('utils.bt_locate.get_bluetooth_scanner') @@ -261,9 +269,9 @@ class TestModuleLevelSessionManagement: assert get_locate_session() is None @patch('utils.bt_locate.get_bluetooth_scanner') - def test_start_replaces_existing_session(self, mock_get_scanner): - mock_scanner = MagicMock() - mock_get_scanner.return_value = mock_scanner + def test_start_replaces_existing_session(self, mock_get_scanner): + mock_scanner = MagicMock() + mock_get_scanner.return_value = mock_scanner target1 = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF') session1 = start_locate_session(target1) @@ -273,6 +281,19 @@ class TestModuleLevelSessionManagement: assert get_locate_session() is session2 assert session1.active is False - assert session2.active is True - - stop_locate_session() + assert session2.active is True + + stop_locate_session() + + @patch('utils.bt_locate.get_bluetooth_scanner') + def test_start_raises_when_scanner_cannot_start(self, mock_get_scanner): + mock_scanner = MagicMock() + mock_scanner.is_scanning = False + mock_scanner.start_scan.return_value = False + status = MagicMock() + status.error = 'No adapter' + mock_scanner.get_status.return_value = status + mock_get_scanner.return_value = mock_scanner + + with pytest.raises(RuntimeError): + start_locate_session(LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')) diff --git a/utils/bt_locate.py b/utils/bt_locate.py index 2277e75..c47935f 100644 --- a/utils/bt_locate.py +++ b/utils/bt_locate.py @@ -18,13 +18,35 @@ from utils.bluetooth.models import BTDeviceAggregate from utils.bluetooth.scanner import BluetoothScanner, get_bluetooth_scanner from utils.gps import get_current_position -logger = logging.getLogger('intercept.bt_locate') +logger = logging.getLogger('intercept.bt_locate') # Maximum trail points to retain MAX_TRAIL_POINTS = 500 # EMA smoothing factor for RSSI -EMA_ALPHA = 0.3 +EMA_ALPHA = 0.3 + + +def _normalize_mac(address: str | None) -> str | None: + """Normalize MAC string to colon-separated uppercase form when possible.""" + if not address: + return None + + text = str(address).strip().upper().replace('-', ':') + if not text: + return None + + # Handle raw 12-hex form: AABBCCDDEEFF + raw = ''.join(ch for ch in text if ch in '0123456789ABCDEF') + if ':' not in text and len(raw) == 12: + text = ':'.join(raw[i:i + 2] for i in range(0, 12, 2)) + + parts = text.split(':') + if len(parts) == 6 and all(len(p) == 2 and all(c in '0123456789ABCDEF' for c in p) for p in parts): + return ':'.join(parts) + + # Return cleaned original when not a strict MAC (caller may still use exact matching) + return text class Environment(Enum): @@ -112,11 +134,11 @@ class LocateTarget: if target_addr_part and dev_addr == target_addr_part: return True - # Match by MAC/address (case-insensitive, normalize separators) + # Match by MAC/address (case-insensitive, normalize separators) if self.mac_address: - dev_addr = (device.address or '').upper().replace('-', ':') - target_addr = self.mac_address.upper().replace('-', ':') - if dev_addr == target_addr: + dev_addr = _normalize_mac(device.address) + target_addr = _normalize_mac(self.mac_address) + if dev_addr and target_addr and dev_addr == target_addr: return True # Match by payload fingerprint (guard against low-stability generic fingerprints) @@ -268,27 +290,33 @@ class LocateSession: # Track last RSSI per device to detect changes self._last_cb_rssi: dict[str, int] = {} # Dedup for rapid callbacks only - def start(self) -> bool: - """Start the locate session. - - Subscribes to scanner callbacks AND runs a polling thread that - checks the aggregator directly (handles bleak scan timeout). - """ - self._scanner = get_bluetooth_scanner() - self._scanner.add_device_callback(self._on_device) - - # Ensure BLE scanning is active - if not self._scanner.is_scanning: - logger.info("BT scanner not running, starting scan for locate session") - self._scanner_started_by_us = True - if not self._scanner.start_scan(mode='auto'): - logger.warning("Failed to start BT scanner for locate session") - else: - self._scanner_started_by_us = False - - self.active = True - self.started_at = datetime.now() - self._stop_event.clear() + def start(self) -> bool: + """Start the locate session. + + Subscribes to scanner callbacks AND runs a polling thread that + checks the aggregator directly (handles bleak scan timeout). + """ + self._scanner = get_bluetooth_scanner() + self._scanner.add_device_callback(self._on_device) + self._scanner_started_by_us = False + + # Ensure BLE scanning is active + if not self._scanner.is_scanning: + logger.info("BT scanner not running, starting scan for locate session") + self._scanner_started_by_us = True + if not self._scanner.start_scan(mode='auto'): + # Surface startup failure to caller and avoid leaving stale callbacks. + status = self._scanner.get_status() + reason = status.error or "unknown error" + logger.warning(f"Failed to start BT scanner for locate session: {reason}") + self._scanner.remove_device_callback(self._on_device) + self._scanner = None + self._scanner_started_by_us = False + return False + + self.active = True + self.started_at = datetime.now() + self._stop_event.clear() # Start polling thread as reliable fallback self._poll_thread = threading.Thread( @@ -550,25 +578,27 @@ _session: LocateSession | None = None _session_lock = threading.Lock() -def start_locate_session( - target: LocateTarget, - environment: Environment = Environment.OUTDOOR, - custom_exponent: float | None = None, - fallback_lat: float | None = None, +def start_locate_session( + target: LocateTarget, + environment: Environment = Environment.OUTDOOR, + custom_exponent: float | None = None, + fallback_lat: float | None = None, fallback_lon: float | None = None, ) -> LocateSession: """Start a new locate session, stopping any existing one.""" global _session - with _session_lock: - if _session and _session.active: - _session.stop() - - _session = LocateSession( - target, environment, custom_exponent, fallback_lat, fallback_lon - ) - _session.start() - return _session + with _session_lock: + if _session and _session.active: + _session.stop() + + _session = LocateSession( + target, environment, custom_exponent, fallback_lat, fallback_lon + ) + if not _session.start(): + _session = None + raise RuntimeError("Bluetooth scanner failed to start") + return _session def stop_locate_session() -> None: