Fix BT Locate startup/map rendering and CelesTrak import reliability

This commit is contained in:
Smittix
2026-02-20 17:35:57 +00:00
parent c0221ba53d
commit c3bf30b49c
6 changed files with 331 additions and 141 deletions
+16 -3
View File
@@ -109,9 +109,22 @@ def start_session():
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})" f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
) )
session = start_locate_session( try:
target, environment, custom_exponent, fallback_lat, fallback_lon 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({ return jsonify({
'status': 'started', 'status': 'started',
+34 -7
View File
@@ -588,14 +588,14 @@ def list_tracked_satellites():
def add_tracked_satellites_endpoint(): def add_tracked_satellites_endpoint():
"""Add one or more tracked satellites.""" """Add one or more tracked satellites."""
global _tle_cache global _tle_cache
data = request.json data = request.get_json(silent=True)
if not data: if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400 return jsonify({'status': 'error', 'message': 'No data provided'}), 400
# Accept a single satellite dict or a list # Accept a single satellite dict or a list
sat_list = data if isinstance(data, list) else [data] sat_list = data if isinstance(data, list) else [data]
added = 0 normalized: list[dict] = []
for sat in sat_list: for sat in sat_list:
norad_id = str(sat.get('norad_id', sat.get('norad', ''))) norad_id = str(sat.get('norad_id', sat.get('norad', '')))
name = sat.get('name', '') name = sat.get('name', '')
@@ -605,19 +605,46 @@ def add_tracked_satellites_endpoint():
tle2 = sat.get('tle_line2', sat.get('tle2')) tle2 = sat.get('tle_line2', sat.get('tle2'))
enabled = sat.get('enabled', True) enabled = sat.get('enabled', True)
if add_tracked_satellite(norad_id, name, tle1, tle2, enabled): normalized.append({
added += 1 '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 # Also inject into TLE cache if we have TLE data
if tle1 and tle2: if tle1 and tle2:
cache_key = name.replace(' ', '-').upper() cache_key = name.replace(' ', '-').upper()
_tle_cache[cache_key] = (name, tle1, tle2) _tle_cache[cache_key] = (name, tle1, tle2)
return jsonify({ # 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', 'status': 'success',
'added': added, 'added': added,
'satellites': get_tracked_satellites(), '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/<norad_id>', methods=['PUT']) @satellite_bp.route('/tracked/<norad_id>', methods=['PUT'])
+95 -10
View File
@@ -38,6 +38,7 @@ const BtLocate = (function() {
let lastRenderedDetectionKey = null; let lastRenderedDetectionKey = null;
let pendingHeatSync = false; let pendingHeatSync = false;
let mapStabilizeTimer = null; let mapStabilizeTimer = null;
let modeActive = false;
const MAX_HEAT_POINTS = 1200; const MAX_HEAT_POINTS = 1200;
const MAX_TRAIL_POINTS = 1200; const MAX_TRAIL_POINTS = 1200;
@@ -85,6 +86,7 @@ const BtLocate = (function() {
} }
function init() { function init() {
modeActive = true;
loadOverlayPreferences(); loadOverlayPreferences();
syncOverlayControls(); syncOverlayControls();
@@ -92,7 +94,7 @@ const BtLocate = (function() {
// Re-invalidate map on re-entry and ensure tiles are present // Re-invalidate map on re-entry and ensure tiles are present
if (map) { if (map) {
setTimeout(() => { setTimeout(() => {
safeInvalidateMap(); safeInvalidateMap(true);
// Re-apply user's tile layer if tiles were lost // Re-apply user's tile layer if tiles were lost
let hasTiles = false; let hasTiles = false;
map.eachLayer(layer => { map.eachLayer(layer => {
@@ -142,7 +144,7 @@ const BtLocate = (function() {
flushPendingHeatSync(); flushPendingHeatSync();
}); });
setTimeout(() => { setTimeout(() => {
safeInvalidateMap(); safeInvalidateMap(true);
flushPendingHeatSync(); flushPendingHeatSync();
}, 100); }, 100);
scheduleMapStabilization(); scheduleMapStabilization();
@@ -173,8 +175,18 @@ const BtLocate = (function() {
.catch(() => {}); .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() { function start() {
const mac = document.getElementById('btLocateMac')?.value.trim(); const mac = normalizeMacInput(document.getElementById('btLocateMac')?.value);
const namePattern = document.getElementById('btLocateNamePattern')?.value.trim(); const namePattern = document.getElementById('btLocateNamePattern')?.value.trim();
const irk = document.getElementById('btLocateIrk')?.value.trim(); const irk = document.getElementById('btLocateIrk')?.value.trim();
@@ -210,7 +222,19 @@ const BtLocate = (function() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
.then(r => r.json()) .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 => { .then(data => {
if (data.status === 'started') { if (data.status === 'started') {
sessionStartedAt = data.session?.started_at ? new Date(data.session.started_at).getTime() : Date.now(); sessionStartedAt = data.session?.started_at ? new Date(data.session.started_at).getTime() : Date.now();
@@ -224,7 +248,11 @@ const BtLocate = (function() {
restoreTrail(); 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() { function stop() {
@@ -888,7 +916,10 @@ const BtLocate = (function() {
if (!map) return; if (!map) return;
ensureHeatLayer(); ensureHeatLayer();
if (!heatLayer) return; if (!heatLayer) return;
if (!isMapContainerVisible()) { if (!modeActive || !isMapContainerVisible()) {
if (map.hasLayer(heatLayer)) {
map.removeLayer(heatLayer);
}
pendingHeatSync = true; pendingHeatSync = true;
return; 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() { function isMapRenderable() {
if (!map || !isMapContainerVisible()) return false; if (!map || !isMapContainerVisible()) return false;
if (typeof map.getSize === 'function') { if (typeof map.getSize === 'function') {
@@ -927,9 +992,26 @@ const BtLocate = (function() {
return true; 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; 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; return true;
} }
@@ -950,7 +1032,7 @@ const BtLocate = (function() {
stopMapStabilization(); stopMapStabilization();
return; return;
} }
if (safeInvalidateMap()) { if (safeInvalidateMap(true)) {
flushPendingHeatSync(); flushPendingHeatSync();
syncMovementLayer(); syncMovementLayer();
syncStrongestMarker(); syncStrongestMarker();
@@ -1624,7 +1706,7 @@ const BtLocate = (function() {
} }
function invalidateMap() { function invalidateMap() {
if (safeInvalidateMap()) { if (safeInvalidateMap(true)) {
flushPendingHeatSync(); flushPendingHeatSync();
syncMovementLayer(); syncMovementLayer();
syncStrongestMarker(); syncStrongestMarker();
@@ -1635,6 +1717,7 @@ const BtLocate = (function() {
return { return {
init, init,
setActiveMode,
start, start,
stop, stop,
handoff, handoff,
@@ -1652,3 +1735,5 @@ const BtLocate = (function() {
fetchPairedIrks, fetchPairedIrks,
}; };
})(); })();
window.BtLocate = BtLocate;
+31 -17
View File
@@ -4004,6 +4004,11 @@
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none'; if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? '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 // Hide sidebar by default for Meshtastic mode, show for others
const mainContent = document.querySelector('.main-content'); const mainContent = document.querySelector('.main-content');
if (mainContent) { if (mainContent) {
@@ -10403,7 +10408,7 @@
fetch('/satellite/celestrak/' + category) fetch('/satellite/celestrak/' + category)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(async data => {
if (data.status === 'success' && data.satellites) { if (data.status === 'success' && data.satellites) {
const toAdd = data.satellites const toAdd = data.satellites
.filter(sat => !trackedSatellites.find(s => s.norad === String(sat.norad))) .filter(sat => !trackedSatellites.find(s => s.norad === String(sat.norad)))
@@ -10420,27 +10425,36 @@
return; return;
} }
fetch('/satellite/tracked', { const batchSize = 250;
method: 'POST', let addedTotal = 0;
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(toAdd) for (let i = 0; i < toAdd.length; i += batchSize) {
}) const batch = toAdd.slice(i, i + batchSize);
.then(r => r.json()) const completed = Math.min(i + batch.length, toAdd.length);
.then(result => { status.innerHTML = `<span style="color: var(--accent-cyan);">Importing ${completed}/${toAdd.length} from ${category}...</span>`;
if (result.status === 'success') {
_loadSatellitesFromAPI(); const resp = await fetch('/satellite/tracked', {
status.innerHTML = `<span style="color: var(--accent-green);">Added ${result.added} satellites (${data.satellites.length} total in category)</span>`; 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}`);
} }
}) addedTotal += Number(result.added || 0);
.catch(() => { }
status.innerHTML = `<span style="color: var(--accent-red);">Failed to save satellites</span>`;
}); _loadSatellitesFromAPI();
status.innerHTML = `<span style="color: var(--accent-green);">Added ${addedTotal} satellites (${data.satellites.length} total in category)</span>`;
} else { } else {
status.innerHTML = `<span style="color: var(--accent-red);">Error: ${data.message || 'Failed to fetch'}</span>`; status.innerHTML = `<span style="color: var(--accent-red);">Error: ${data.message || 'Failed to fetch'}</span>`;
} }
}) })
.catch(() => { .catch((err) => {
status.innerHTML = `<span style="color: var(--accent-red);">Network error</span>`; const msg = err && err.message ? err.message : 'Network error';
status.innerHTML = `<span style="color: var(--accent-red);">Import failed: ${msg}</span>`;
}); });
} }
+21
View File
@@ -136,6 +136,14 @@ class TestLocateTarget:
device.name = None device.name = None
assert target.matches(device) is True 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): def test_match_by_name_pattern(self):
target = LocateTarget(name_pattern='iPhone') target = LocateTarget(name_pattern='iPhone')
device = MagicMock() device = MagicMock()
@@ -276,3 +284,16 @@ class TestModuleLevelSessionManagement:
assert session2.active is True assert session2.active is True
stop_locate_session() 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'))
+37 -7
View File
@@ -27,6 +27,28 @@ MAX_TRAIL_POINTS = 500
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): class Environment(Enum):
"""RF propagation environment presets.""" """RF propagation environment presets."""
FREE_SPACE = 2.0 FREE_SPACE = 2.0
@@ -114,9 +136,9 @@ class LocateTarget:
# Match by MAC/address (case-insensitive, normalize separators) # Match by MAC/address (case-insensitive, normalize separators)
if self.mac_address: if self.mac_address:
dev_addr = (device.address or '').upper().replace('-', ':') dev_addr = _normalize_mac(device.address)
target_addr = self.mac_address.upper().replace('-', ':') target_addr = _normalize_mac(self.mac_address)
if dev_addr == target_addr: if dev_addr and target_addr and dev_addr == target_addr:
return True return True
# Match by payload fingerprint (guard against low-stability generic fingerprints) # Match by payload fingerprint (guard against low-stability generic fingerprints)
@@ -276,15 +298,21 @@ class LocateSession:
""" """
self._scanner = get_bluetooth_scanner() self._scanner = get_bluetooth_scanner()
self._scanner.add_device_callback(self._on_device) self._scanner.add_device_callback(self._on_device)
self._scanner_started_by_us = False
# Ensure BLE scanning is active # Ensure BLE scanning is active
if not self._scanner.is_scanning: if not self._scanner.is_scanning:
logger.info("BT scanner not running, starting scan for locate session") logger.info("BT scanner not running, starting scan for locate session")
self._scanner_started_by_us = True self._scanner_started_by_us = True
if not self._scanner.start_scan(mode='auto'): if not self._scanner.start_scan(mode='auto'):
logger.warning("Failed to start BT scanner for locate session") # Surface startup failure to caller and avoid leaving stale callbacks.
else: status = self._scanner.get_status()
self._scanner_started_by_us = False 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.active = True
self.started_at = datetime.now() self.started_at = datetime.now()
@@ -567,7 +595,9 @@ def start_locate_session(
_session = LocateSession( _session = LocateSession(
target, environment, custom_exponent, fallback_lat, fallback_lon target, environment, custom_exponent, fallback_lat, fallback_lon
) )
_session.start() if not _session.start():
_session = None
raise RuntimeError("Bluetooth scanner failed to start")
return _session return _session