mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-10-30 23:51:57 +01:00
A tester's cached-contacts map came up empty and Path Analyzer drew routes across the globe. Two of his 2131 cached contacts carried garbage positions from corrupted adverts - lat 1642 / lon -323 and lat 309 / lon -1768. Leaflet clamps latitude when projecting but not longitude, so fitBounds() spanned 1797 degrees, dropped to zoom 0 and centred on lon -870; all 2273 real markers were rendered several world-widths outside the viewport. Reproduced against his DB: zoom 0, bounds -1431..-309. The device-only view was fine because both bad rows are cache-only. Four layers, so no single miss can blank a map again: - app/geo.py: one definition of a usable coordinate, applied on write in Database.upsert_contact() so the cache stops storing garbage, and on API output so a cache poisoned by an older build heals itself with no migration. Corrupted advert types (61, 126, 152, 210 seen in the wild) are logged but kept raw - they already render as UNKNOWN. - static/js/geo-utils.js: hasValidGps()/withValidGps()/fitMapToPoints() replace eight copies of the same check across six files. Path Analyzer had a looser variant that also accepted lon == 0, which put nodes off west Africa and stretched routes across the map; it now shares the rule. - Per-marker try/catch and a bounds validity check, so an entry that still slips through costs its own pin rather than the whole map. Verified against the tester's DB in a container: cached map 2273 markers, lon span 1797 -> 19 degrees, centred on Poland; the route that drew the world-spanning line (msg 29499, token 72AF) now renders as a real Krakow-Kielce path with the bad hop left unresolved. Live local instance regression-checked on all five map pages. tests/test_geo.py: 43 passed; full suite 290 passed, 2 pre-existing failures unrelated to this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
91 lines
3.3 KiB
Python
91 lines
3.3 KiB
Python
"""
|
|
Coordinate sanity checks for advert-sourced positions.
|
|
|
|
Adverts arrive from the mesh unvalidated, and a corrupted packet can carry a
|
|
latitude of 1642 or a longitude of -1768. A single such row poisons every map
|
|
that includes it: Leaflet clamps latitude when projecting but not longitude,
|
|
so fitBounds() zooms out to the whole world and pushes every real marker
|
|
off-screen. Reported by a tester on 2026-08-28 - the cached-contacts map was
|
|
blank because two of 2131 cached contacts carried garbage coordinates.
|
|
|
|
Coordinates are therefore checked twice: on write, so the cache never stores
|
|
them again, and on API output, so a cache already poisoned by an older build
|
|
heals itself without a migration.
|
|
|
|
The JavaScript counterpart is static/js/geo-utils.js - keep the two in sync.
|
|
"""
|
|
|
|
import math
|
|
import logging
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MAX_LAT = 90.0
|
|
MAX_LON = 180.0
|
|
|
|
# MeshCore advert types: 0=none, 1=chat/companion, 2=repeater, 3=room, 4=sensor.
|
|
# Anything else means the advert was corrupted in flight.
|
|
VALID_CONTACT_TYPES = frozenset((0, 1, 2, 3, 4))
|
|
|
|
|
|
def _coord(value: Any, limit: float) -> Optional[float]:
|
|
"""Return value as a float inside +/-limit, or None if it is unusable."""
|
|
if value is None:
|
|
return None
|
|
try:
|
|
num = float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if not math.isfinite(num) or abs(num) > limit:
|
|
return None
|
|
return num
|
|
|
|
|
|
def sanitize_latlon(lat: Any, lon: Any) -> Tuple[Optional[float], Optional[float]]:
|
|
"""
|
|
Return (lat, lon) as floats, or (None, None) if either value is unusable.
|
|
|
|
Both are dropped together on purpose: half a position is not a position,
|
|
and leaving one component behind would still skew map bounds.
|
|
"""
|
|
clean_lat = _coord(lat, MAX_LAT)
|
|
clean_lon = _coord(lon, MAX_LON)
|
|
if clean_lat is None or clean_lon is None:
|
|
return None, None
|
|
return clean_lat, clean_lon
|
|
|
|
|
|
def sanitize_contact_latlon(lat: Any, lon: Any, label: str = '') -> Tuple[Optional[float], Optional[float]]:
|
|
"""sanitize_latlon() that logs whatever it had to throw away."""
|
|
clean_lat, clean_lon = sanitize_latlon(lat, lon)
|
|
if clean_lat is None and (lat is not None or lon is not None):
|
|
logger.warning(
|
|
f"Dropping out-of-range advert coordinates for {label or 'contact'}: "
|
|
f"lat={lat!r} lon={lon!r}"
|
|
)
|
|
return clean_lat, clean_lon
|
|
|
|
|
|
def is_valid_contact_type(value: Any) -> bool:
|
|
"""True for a MeshCore advert type we know how to render."""
|
|
return isinstance(value, int) and not isinstance(value, bool) and value in VALID_CONTACT_TYPES
|
|
|
|
|
|
def scrub_geo(payload: Dict, lat_key: str = 'adv_lat', lon_key: str = 'adv_lon') -> Dict:
|
|
"""
|
|
Null out unusable coordinates in an outgoing API payload (in place).
|
|
|
|
Applied to every response that feeds a map, so a contact cached by an
|
|
older build cannot blank the map it appears on. Keys the payload does not
|
|
already carry are left alone.
|
|
"""
|
|
if lat_key not in payload and lon_key not in payload:
|
|
return payload
|
|
lat, lon = sanitize_latlon(payload.get(lat_key), payload.get(lon_key))
|
|
if lat_key in payload:
|
|
payload[lat_key] = lat
|
|
if lon_key in payload:
|
|
payload[lon_key] = lon
|
|
return payload
|