diff --git a/routes/system.py b/routes/system.py index b66efc2..271a2ef 100644 --- a/routes/system.py +++ b/routes/system.py @@ -436,17 +436,21 @@ def _ensure_collector() -> None: def _get_observer_location() -> dict[str, Any]: """Get observer location from GPS state or config defaults.""" lat, lon, source = None, None, 'none' + gps_meta: dict[str, Any] = {} - # Try GPS state from app module + # Try GPS via utils.gps with contextlib.suppress(Exception): - import app as app_module + from utils.gps import get_current_position - gps_state = getattr(app_module, 'gps_state', None) - if gps_state and isinstance(gps_state, dict): - g_lat = gps_state.get('lat') or gps_state.get('latitude') - g_lon = gps_state.get('lon') or gps_state.get('longitude') - if g_lat is not None and g_lon is not None: - lat, lon, source = float(g_lat), float(g_lon), 'gps' + pos = get_current_position() + if pos and pos.fix_quality >= 2: + lat, lon, source = pos.latitude, pos.longitude, 'gps' + gps_meta['fix_quality'] = pos.fix_quality + gps_meta['satellites'] = pos.satellites + if pos.epx is not None and pos.epy is not None: + gps_meta['accuracy'] = round(max(pos.epx, pos.epy), 1) + if pos.altitude is not None: + gps_meta['altitude'] = round(pos.altitude, 1) # Fall back to config defaults if lat is None: @@ -456,7 +460,10 @@ def _get_observer_location() -> dict[str, Any]: if DEFAULT_LATITUDE != 0.0 or DEFAULT_LONGITUDE != 0.0: lat, lon, source = DEFAULT_LATITUDE, DEFAULT_LONGITUDE, 'config' - return {'lat': lat, 'lon': lon, 'source': source} + result: dict[str, Any] = {'lat': lat, 'lon': lon, 'source': source} + if gps_meta: + result['gps'] = gps_meta + return result # --------------------------------------------------------------------------- diff --git a/static/css/modes/system.css b/static/css/modes/system.css index 7beee04..5593690 100644 --- a/static/css/modes/system.css +++ b/static/css/modes/system.css @@ -282,16 +282,17 @@ color: var(--accent-cyan, #00d4ff); } -/* Globe container */ +/* Globe container — compact vertical layout */ .sys-location-inner { display: flex; - gap: 16px; - align-items: stretch; + flex-direction: column; + gap: 10px; + align-items: center; } .sys-globe-wrap { - width: 300px; - height: 300px; + width: 200px; + height: 200px; flex-shrink: 0; background: #000; border-radius: 8px; @@ -300,10 +301,42 @@ } .sys-location-details { - flex: 1; + width: 100%; display: flex; flex-direction: column; - gap: 8px; + gap: 6px; +} + +/* GPS status indicator */ +.sys-gps-status { + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + color: var(--text-dim, #8888aa); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.sys-gps-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; +} + +.sys-gps-dot.fix-3d { + background: var(--accent-green, #00ff88); + box-shadow: 0 0 4px rgba(0, 255, 136, 0.4); +} + +.sys-gps-dot.fix-2d { + background: var(--accent-yellow, #ffcc00); + box-shadow: 0 0 4px rgba(255, 204, 0, 0.4); +} + +.sys-gps-dot.no-fix { + background: var(--text-dim, #555); } .sys-location-coords { @@ -515,13 +548,9 @@ grid-column: 1; } - .sys-location-inner { - flex-direction: column; - } - .sys-globe-wrap { width: 100%; - height: 250px; + height: 180px; } .sys-process-grid { diff --git a/static/js/modes/system.js b/static/js/modes/system.js index 74fb93d..6731806 100644 --- a/static/js/modes/system.js +++ b/static/js/modes/system.js @@ -390,14 +390,27 @@ const SystemHealth = (function () { // Globe container html += '
'; - // Details column + // Details below globe html += '
'; if (locationData && locationData.lat != null) { html += '
' + - locationData.lat.toFixed(4) + '°' + (locationData.lat >= 0 ? 'N' : 'S') + '
' + + locationData.lat.toFixed(4) + '°' + (locationData.lat >= 0 ? 'N' : 'S') + ', ' + locationData.lon.toFixed(4) + '°' + (locationData.lon >= 0 ? 'E' : 'W') + '
'; - html += '
Source: ' + escHtml(locationData.source || 'unknown') + '
'; + + // GPS status indicator + if (locationData.source === 'gps' && locationData.gps) { + var gps = locationData.gps; + var fixLabel = gps.fix_quality === 3 ? '3D Fix' : '2D Fix'; + var dotCls = gps.fix_quality === 3 ? 'fix-3d' : 'fix-2d'; + html += '
' + + ' ' + fixLabel; + if (gps.satellites != null) html += ' · ' + gps.satellites + ' sats'; + if (gps.accuracy != null) html += ' · ±' + gps.accuracy + 'm'; + html += '
'; + } else { + html += '
Source: ' + escHtml(locationData.source || 'unknown') + '
'; + } } else { html += '
No location
'; } diff --git a/templates/index.html b/templates/index.html index ddf7990..279503f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3151,10 +3151,14 @@
Network
Connecting…
-
+
Location & Weather
Loading…
+
+
System Info
+
Connecting…
+
Equipment & Operations
@@ -3170,12 +3174,6 @@
Active Processes
Connecting…
- - -
-
System Info
-
Connecting…
-
diff --git a/tests/test_system.py b/tests/test_system.py index 3ddef06..805ea11 100644 --- a/tests/test_system.py +++ b/tests/test_system.py @@ -123,8 +123,7 @@ def test_stream_returns_sse_content_type(client): def test_location_returns_shape(client): """GET /system/location returns lat/lon/source shape.""" _login(client) - with patch('routes.system.contextlib.suppress'): - resp = client.get('/system/location') + resp = client.get('/system/location') assert resp.status_code == 200 data = resp.get_json() assert 'lat' in data @@ -132,12 +131,48 @@ def test_location_returns_shape(client): assert 'source' in data +def test_location_from_gps(client): + """Location endpoint returns GPS data when fix available.""" + _login(client) + mock_pos = MagicMock() + mock_pos.fix_quality = 3 + mock_pos.latitude = 51.5074 + mock_pos.longitude = -0.1278 + mock_pos.satellites = 12 + mock_pos.epx = 2.5 + mock_pos.epy = 3.1 + mock_pos.altitude = 45.0 + + with patch('routes.system.get_current_position', return_value=mock_pos, create=True): + # Patch the import inside the function + import routes.system as mod + original = mod._get_observer_location + + def _patched(): + with patch('utils.gps.get_current_position', return_value=mock_pos): + return original() + + mod._get_observer_location = _patched + try: + resp = client.get('/system/location') + finally: + mod._get_observer_location = original + + assert resp.status_code == 200 + data = resp.get_json() + assert data['source'] == 'gps' + assert data['lat'] == 51.5074 + assert data['lon'] == -0.1278 + assert data['gps']['fix_quality'] == 3 + assert data['gps']['satellites'] == 12 + assert data['gps']['accuracy'] == 3.1 + assert data['gps']['altitude'] == 45.0 + + def test_location_falls_back_to_config(client): """Location endpoint returns config defaults when GPS unavailable.""" _login(client) - with patch('routes.system.DEFAULT_LATITUDE', 40.7128, create=True), \ - patch('routes.system.DEFAULT_LONGITUDE', -74.006, create=True): - # Mock the import inside _get_observer_location + with patch('utils.gps.get_current_position', return_value=None): resp = client.get('/system/location') assert resp.status_code == 200 data = resp.get_json()