Files
StarPilot/starpilot/navigation/destination_store.py
T
firestarsdog b290167194 A Quickie
2026-08-13 15:43:43 -04:00

402 lines
12 KiB
Python

from __future__ import annotations
import json
import hashlib
import math
from typing import Any
NAVIGATION_DESTINATION_KEY = "NavDestination"
RECENT_DESTINATIONS_KEY = "ApiCache_NavDestinations"
FAVORITE_DESTINATIONS_KEY = "FavoriteDestinations"
NAV_INSTRUCTION_STATE_KEY = "NavInstructionState"
NAV_INSTRUCTION_COLLAPSED_KEY = "NavInstructionCollapsed"
RECENT_DESTINATIONS_LIMIT = 10
def _coerce_float(value: Any) -> float | None:
try:
parsed = float(value)
except (OverflowError, TypeError, ValueError):
return None
return parsed if math.isfinite(parsed) else None
def _json_value(raw_value: Any, default: Any) -> Any:
if isinstance(raw_value, (list, dict)):
return raw_value
if isinstance(raw_value, bytes):
raw_value = raw_value.decode("utf-8", errors="replace")
if not raw_value:
return default
try:
return json.loads(raw_value)
except (TypeError, ValueError):
return default
def _param_get(params: Any, key: str, default: Any = "") -> Any:
try:
return params.get(key, encoding="utf-8", default=default)
except TypeError:
try:
return params.get(key, default=default)
except TypeError:
return params.get(key)
def _text(value: Any) -> str:
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return str(value or "")
def normalize_destination_payload(payload: Any) -> dict[str, Any] | None:
if not isinstance(payload, dict):
return None
name = str(payload.get("place_name") or payload.get("name") or "").strip()
latitude = _coerce_float(payload.get("latitude"))
longitude = _coerce_float(payload.get("longitude"))
if not name or latitude is None or longitude is None:
return None
return {
"name": name,
"place_name": name,
"latitude": latitude,
"longitude": longitude,
}
def parse_destination_json(raw_value: str | bytes | dict[str, Any] | None) -> dict[str, Any] | None:
if not raw_value:
return None
payload = _json_value(raw_value, None)
if payload is None:
return None
return normalize_destination_payload(payload)
def _favorite_destination_id(payload: dict[str, Any]) -> str:
"""Keep the exact favorite ID formula used by Galaxy's existing backend."""
raw = f"{payload.get('longitude')},{payload.get('latitude')}|{payload.get('routeId') or ''}|{payload.get('name') or ''}"
return hashlib.sha1(raw.encode()).hexdigest()
def favorite_destination_id(payload: dict[str, Any]) -> str:
return _favorite_destination_id(payload)
def favorite_payload_for_galaxy(destination: dict[str, Any]) -> dict[str, Any]:
favorite = dict(destination)
favorite.setdefault("routeId", "main")
return favorite
def normalize_favorite_destination(payload: Any) -> dict[str, Any] | None:
if not isinstance(payload, dict):
return None
name = str(payload.get("name") or payload.get("place_name") or "").strip()
latitude = _coerce_float(payload.get("latitude"))
longitude = _coerce_float(payload.get("longitude"))
if not name or latitude is None or longitude is None:
return None
normalized = dict(payload)
normalized.update({"name": name, "latitude": latitude, "longitude": longitude})
normalized["id"] = str(payload.get("id") or _favorite_destination_id(payload))
return normalized
def load_favorite_destinations(raw_value: Any) -> list[dict[str, Any]]:
payload = _json_value(raw_value, [])
if not isinstance(payload, list):
return []
return [
normalized
for entry in payload
if (normalized := normalize_favorite_destination(entry)) is not None
]
def ordered_favorite_destinations(favorites: list[dict[str, Any]], limit: int | None = None) -> list[dict[str, Any]]:
normalized = [
favorite
for entry in favorites
if (favorite := normalize_favorite_destination(entry)) is not None
]
def order_key(favorite: dict[str, Any]) -> tuple[bool, bool, str]:
return (
not bool(favorite.get("is_home")),
not bool(favorite.get("is_work")),
str(favorite.get("name") or "").casefold(),
)
ordered = sorted(normalized, key=order_key)
return ordered if limit is None else ordered[:max(0, limit)]
def same_destination(left: dict[str, Any] | None, right: dict[str, Any] | None) -> bool:
if not left or not right:
return False
try:
left_latitude = _coerce_float(left.get("latitude"))
right_latitude = _coerce_float(right.get("latitude"))
left_longitude = _coerce_float(left.get("longitude"))
right_longitude = _coerce_float(right.get("longitude"))
if (
left_latitude is None or right_latitude is None or
left_longitude is None or right_longitude is None
):
return False
return bool(
abs(left_latitude - right_latitude) <= 1e-6 and
abs(left_longitude - right_longitude) <= 1e-6
)
except (TypeError, ValueError):
return False
def routing_configured(params: Any) -> bool:
return bool(_text(_param_get(params, "MapboxSecretKey", "")).strip())
def set_navigation_destination(
params: Any,
payload: Any,
*,
skip_if_same: bool = False,
) -> dict[str, Any] | None:
destination = normalize_destination_payload(payload)
if destination is None:
return None
if skip_if_same:
current = parse_destination_json(_param_get(params, NAVIGATION_DESTINATION_KEY, ""))
if same_destination(current, destination):
return destination
raw_recent_destinations = _param_get(params, RECENT_DESTINATIONS_KEY, "[]")
if isinstance(raw_recent_destinations, (list, dict)):
raw_recent_destinations = json.dumps(raw_recent_destinations)
recent_destinations = update_recent_destinations(raw_recent_destinations, destination)
params.put(NAVIGATION_DESTINATION_KEY, json.dumps(destination))
params.put(RECENT_DESTINATIONS_KEY, recent_destinations)
return destination
def favorite_matches_target(
favorite: dict[str, Any],
target: dict[str, Any],
*,
allow_route_id_only: bool = False,
) -> bool:
target_id = target.get("id")
if target_id:
return bool(favorite.get("id") == target_id)
if allow_route_id_only and target.get("routeId"):
return bool(favorite.get("routeId") == target.get("routeId"))
return bool(
favorite.get("routeId") == target.get("routeId") and
favorite.get("latitude") == target.get("latitude") and
favorite.get("longitude") == target.get("longitude") and
favorite.get("name") == target.get("name")
)
def add_favorite_destination(raw_value: Any, favorite: dict[str, Any]) -> list[dict[str, Any]]:
favorites = load_favorite_destinations(raw_value)
normalized = normalize_favorite_destination(favorite)
if normalized is not None and not any(item.get("id") == normalized["id"] for item in favorites):
favorites.append(normalized)
return favorites
def remove_favorite_destination(raw_value: Any, target: dict[str, Any]) -> list[dict[str, Any]]:
favorites = load_favorite_destinations(raw_value)
return [favorite for favorite in favorites if not favorite_matches_target(favorite, target)]
def update_favorite_destination(
raw_value: Any,
target: dict[str, Any],
*,
name: str | None = None,
is_home: bool | None = None,
is_work: bool | None = None,
) -> list[dict[str, Any]] | None:
favorites = load_favorite_destinations(raw_value)
target_index = next(
(
index for index, favorite in enumerate(favorites)
if favorite_matches_target(favorite, target, allow_route_id_only=True)
),
None,
)
if target_index is None:
return None
if is_home:
for favorite in favorites:
favorite.pop("is_home", None)
if is_work:
for favorite in favorites:
favorite.pop("is_work", None)
favorite = favorites[target_index]
if name is not None:
normalized_name = str(name).strip()
if normalized_name:
favorite["name"] = normalized_name
if is_home is not None:
if is_home:
favorite["is_home"] = True
favorite.pop("is_work", None)
else:
favorite.pop("is_home", None)
if is_work is not None:
if is_work:
favorite["is_work"] = True
favorite.pop("is_home", None)
else:
favorite.pop("is_work", None)
return favorites
def normalize_recent_destination_entry(entry: Any) -> dict[str, Any] | None:
if not isinstance(entry, dict):
return None
place_name = str(entry.get("place_name") or entry.get("name") or "").strip()
if not place_name:
return None
normalized: dict[str, Any] = {"place_name": place_name}
latitude = _coerce_float(entry.get("latitude"))
longitude = _coerce_float(entry.get("longitude"))
if latitude is not None and longitude is not None:
normalized["latitude"] = latitude
normalized["longitude"] = longitude
normalized["name"] = place_name
return normalized
def load_recent_destinations(raw_value: str | bytes | None) -> list[dict[str, Any]]:
if not raw_value:
return []
try:
payload = json.loads(raw_value)
except (TypeError, ValueError, json.JSONDecodeError):
return []
if not isinstance(payload, list):
return []
normalized: list[dict[str, Any]] = []
for entry in payload:
recent = normalize_recent_destination_entry(entry)
if recent is not None:
normalized.append(recent)
return normalized
def update_recent_destinations(raw_value: str | bytes | None, destination: dict[str, Any], limit: int = RECENT_DESTINATIONS_LIMIT) -> list[dict[str, Any]]:
normalized_destination = normalize_destination_payload(destination)
if normalized_destination is None:
return load_recent_destinations(raw_value)[:limit]
current = load_recent_destinations(raw_value)
updated = [normalize_recent_destination_entry(normalized_destination)]
seen = {normalized_destination["place_name"].casefold()}
for entry in current:
place_name = entry["place_name"].casefold()
if place_name in seen:
continue
seen.add(place_name)
updated.append(entry)
if len(updated) >= limit:
break
return [entry for entry in updated if entry is not None]
class NavigationDestinationStore:
"""Shared local storage boundary for navigation destinations and favorites."""
def __init__(self, params: Any, params_memory: Any | None = None):
self.params = params
self.params_memory = params_memory or params
def active_destination(self) -> dict[str, Any] | None:
return parse_destination_json(_param_get(self.params, NAVIGATION_DESTINATION_KEY, ""))
def recent_destinations(self) -> list[dict[str, Any]]:
raw_value = _param_get(self.params, RECENT_DESTINATIONS_KEY, "[]")
if isinstance(raw_value, (list, dict)):
raw_value = json.dumps(raw_value)
return load_recent_destinations(raw_value)
def favorite_destinations(self) -> list[dict[str, Any]]:
raw_value = _param_get(self.params, FAVORITE_DESTINATIONS_KEY, "[]")
favorites = load_favorite_destinations(raw_value)
raw_payload = _json_value(raw_value, [])
if isinstance(raw_payload, list) and any(
isinstance(entry, dict) and not entry.get("id") for entry in raw_payload
):
self.params.put(FAVORITE_DESTINATIONS_KEY, favorites)
return favorites
def set_destination(
self,
payload: Any,
*,
skip_if_same: bool = False,
) -> dict[str, Any] | None:
return set_navigation_destination(self.params, payload, skip_if_same=skip_if_same)
def clear_navigation(self) -> bool:
collapsed_supported = True
for params, key in (
(self.params, NAVIGATION_DESTINATION_KEY),
(self.params_memory, NAV_INSTRUCTION_STATE_KEY),
(self.params_memory, NAV_INSTRUCTION_COLLAPSED_KEY),
):
try:
params.remove(key)
except Exception:
if key == NAV_INSTRUCTION_COLLAPSED_KEY:
collapsed_supported = False
return collapsed_supported
def add_favorite(self, favorite: dict[str, Any]) -> list[dict[str, Any]]:
updated = add_favorite_destination(_param_get(self.params, FAVORITE_DESTINATIONS_KEY, "[]"), favorite)
self.params.put(FAVORITE_DESTINATIONS_KEY, updated)
return updated
def remove_favorite(self, favorite: dict[str, Any]) -> list[dict[str, Any]]:
updated = remove_favorite_destination(_param_get(self.params, FAVORITE_DESTINATIONS_KEY, "[]"), favorite)
self.params.put(FAVORITE_DESTINATIONS_KEY, updated)
return updated
def update_favorite(self, favorite: dict[str, Any], **changes: Any) -> list[dict[str, Any]] | None:
updated = update_favorite_destination(
_param_get(self.params, FAVORITE_DESTINATIONS_KEY, "[]"),
favorite,
**changes,
)
if updated is not None:
self.params.put(FAVORITE_DESTINATIONS_KEY, updated)
return updated
def routing_configured(self) -> bool:
return routing_configured(self.params)