Travel assistant for NanoClaw: byAir flight notifications (delay, gate, connection risk, inbound aircraft delay, time-to-leave, arrival logistics), travel-booking gap checks, and nightly TripIt sync. Per-chat overlay tile.
77
96%
Does it follow best practices?
Impact
—
No eval scenarios have been run
Advisory
Suggest reviewing before use
"""Traffic-aware travel time with a Google-primary, TomTom-backup chain.
Used by `phase_markers.py` to compute the "leave by" deadline for the
time-to-leave capability: given the user's current origin and the
flight's departure airport, return the duration with current traffic.
The same client is the shared ground-transit time source for the
`drive-planner` skill (meetings and airports alike).
Routing chain (mission-critical — "I don't like to be late"):
1. Google Distance Matrix (primary). Free-form origin/destination
strings, returns duration with and without traffic in one call.
2. TomTom (backup), used only when Google fails. TomTom routing is
coordinates-only, so the backup is *geocode each endpoint →
route between the coordinates*, not a drop-in. The resulting
`TravelTime.source` is `"tomtom"` so callers can tell which
provider answered.
There is deliberately NO no-traffic fallback (e.g. OSRM): a duration
without a live-traffic model is false confidence for a leave-by
deadline. When both providers fail, fail honestly with `MapsError`.
stdlib-only: `urllib.request` + `urllib.parse` + `json` per
`jbaruch/coding-policy: dependency-management` (Stdlib First).
Public API:
# The skill bundle dir is added to sys.path at invocation time; this
# module is imported by its bare name (matches nanoclaw-core's convention).
from maps_client import MapsClient, MapsError, TravelTime
client = MapsClient.from_env() # wires the TomTom backup if TOMTOM_API_KEY is set
result = client.travel_time(
origin="1 Infinite Loop, Cupertino, CA",
destination="SFO airport",
)
print(result.in_traffic_seconds, result.traffic_factor, result.source)
Errors:
- `MapsError` wraps non-OK provider statuses (Google `NOT_FOUND`,
`ZERO_RESULTS`, `OVER_QUERY_LIMIT`; TomTom geocode/route failures)
and, when the whole chain is exhausted, status
`ALL_PROVIDERS_FAILED` carrying what each provider reported
- HTTP / network errors from a provider with no configured backup
propagate as `urllib.error.URLError` / `urllib.error.HTTPError`
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
_GOOGLE_ENDPOINT = "https://maps.googleapis.com/maps/api/distancematrix/json"
_TOMTOM_GEOCODE_ENDPOINT = "https://api.tomtom.com/search/2/geocode"
_TOMTOM_ROUTE_ENDPOINT = "https://api.tomtom.com/routing/1/calculateRoute"
class MapsError(Exception):
"""Raised when a routing provider returns a non-OK status."""
def __init__(self, status: str, message: str):
super().__init__(f"{status}: {message}")
self.status = status
self.message = message
@dataclass(frozen=True)
class TravelTime:
"""One origin→destination travel-time result from a routing provider.
Fields:
duration_seconds: Free-flow duration (no current-traffic model)
in_traffic_seconds: Duration with current traffic conditions
(None when the provider didn't return a traffic estimate).
Use this for the time-to-leave deadline when present.
traffic_factor: in_traffic / duration ratio (1.0 when no traffic
data; >1.0 when current traffic slows travel)
distance_meters: Distance between origin and destination
origin_resolved: The address the provider matched the origin to
destination_resolved: The address the provider matched the dest to
source: Which provider answered — "google" or "tomtom"
"""
duration_seconds: int
in_traffic_seconds: int | None
traffic_factor: float
distance_meters: int
origin_resolved: str
destination_resolved: str
source: str
def _get_json(url: str, timeout: float) -> dict:
"""GET a URL and decode the JSON body. Transport errors propagate."""
request = urllib.request.Request(
url,
headers={"Accept": "application/json"},
method="GET",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
def _traffic_factor(duration: int, in_traffic: int | None) -> float:
if in_traffic is not None and duration > 0:
return in_traffic / duration
return 1.0
class TomTomClient:
"""TomTom geocode + routing backup.
TomTom routing takes coordinates, not free-form strings, so a single
`travel_time()` is three HTTP calls: geocode the origin, geocode the
destination, then route between the two coordinate pairs with
`traffic=true`. Each call is billable against the TomTom free tier.
"""
def __init__(self, api_key: str, *, timeout: float = 10.0):
if not api_key:
raise ValueError(
"TomTomClient: api_key is empty — set TOMTOM_API_KEY in the env "
"(create a free key at https://developer.tomtom.com/)"
)
self._api_key = api_key
self._timeout = timeout
@classmethod
def from_env(cls, *, env_var: str = "TOMTOM_API_KEY", timeout: float = 10.0) -> TomTomClient:
"""Construct from the TOMTOM_API_KEY env var."""
api_key = os.environ.get(env_var, "")
if not api_key:
raise ValueError(
f"TomTomClient.from_env: ${env_var} is unset — create a free key at "
f"https://developer.tomtom.com/ and add it to OneCLI vault"
)
return cls(api_key, timeout=timeout)
def travel_time(self, origin: str, destination: str) -> TravelTime:
"""Geocode both endpoints, then route between them with live traffic.
Raises:
ValueError: if either argument is empty
MapsError: on geocode zero-results or a malformed route response
urllib.error.HTTPError: on transport failure
"""
if not origin or not destination:
raise ValueError("travel_time: origin and destination are required")
origin_lat, origin_lon, origin_resolved = self._geocode(origin)
dest_lat, dest_lon, dest_resolved = self._geocode(destination)
return self._route(
(origin_lat, origin_lon, origin_resolved),
(dest_lat, dest_lon, dest_resolved),
)
def _geocode(self, query: str) -> tuple[float, float, str]:
encoded = urllib.parse.quote(query, safe="")
params = urllib.parse.urlencode({"key": self._api_key, "limit": 1})
url = f"{_TOMTOM_GEOCODE_ENDPOINT}/{encoded}.json?{params}"
payload = _get_json(url, self._timeout)
results = payload.get("results") or []
if not results:
raise MapsError(
"TOMTOM_GEOCODE_ZERO_RESULTS",
f"TomTom geocode returned no match for {query!r}",
)
position = results[0].get("position") or {}
lat = position.get("lat")
lon = position.get("lon")
if lat is None or lon is None:
raise MapsError(
"TOMTOM_MALFORMED_RESPONSE",
f"TomTom geocode result missing lat/lon for {query!r}",
)
resolved = (results[0].get("address") or {}).get("freeformAddress") or query
return float(lat), float(lon), resolved
def _route(
self,
origin: tuple[float, float, str],
destination: tuple[float, float, str],
) -> TravelTime:
origin_lat, origin_lon, origin_resolved = origin
dest_lat, dest_lon, dest_resolved = destination
path = f"{origin_lat},{origin_lon}:{dest_lat},{dest_lon}"
params = urllib.parse.urlencode(
{
"key": self._api_key,
"traffic": "true",
"travelMode": "car",
"computeTravelTimeFor": "all",
}
)
url = f"{_TOMTOM_ROUTE_ENDPOINT}/{path}/json?{params}"
payload = _get_json(url, self._timeout)
routes = payload.get("routes") or []
if not routes:
raise MapsError(
"TOMTOM_ZERO_RESULTS",
f"TomTom found no route from {origin_resolved} → {dest_resolved}",
)
summary = routes[0].get("summary") or {}
travel_time = summary.get("travelTimeInSeconds")
distance = summary.get("lengthInMeters")
if travel_time is None or distance is None:
raise MapsError(
"TOMTOM_MALFORMED_RESPONSE",
"TomTom route summary missing travelTimeInSeconds or lengthInMeters",
)
# With traffic=true, travelTimeInSeconds already includes the live
# delay; computeTravelTimeFor=all adds the free-flow baseline so we
# can report both, matching the Google duration / in_traffic split.
free_flow = summary.get("noTrafficTravelTimeInSeconds")
if free_flow is not None:
duration = int(free_flow)
in_traffic: int | None = int(travel_time)
else:
duration = int(travel_time)
in_traffic = None
return TravelTime(
duration_seconds=duration,
in_traffic_seconds=in_traffic,
traffic_factor=_traffic_factor(duration, in_traffic),
distance_meters=int(distance),
origin_resolved=origin_resolved,
destination_resolved=dest_resolved,
source="tomtom",
)
class MapsClient:
"""Google Distance Matrix primary with an optional TomTom backup.
Each `travel_time()` call is one billable Google Distance Matrix
request (and, only on Google failure, up to three TomTom requests).
Cache aggressively at the caller level — for the time-to-leave
capability, one or two queries per flight per day is the target.
"""
def __init__(
self,
api_key: str,
*,
timeout: float = 10.0,
tomtom: TomTomClient | None = None,
):
if not api_key:
raise ValueError(
"MapsClient: api_key is empty — set GOOGLE_MAPS_API_KEY in the env "
"(generate at https://console.cloud.google.com/apis/credentials and "
"enable the Distance Matrix API)"
)
self._api_key = api_key
self._timeout = timeout
self._tomtom = tomtom
@classmethod
def from_env(
cls,
*,
env_var: str = "GOOGLE_MAPS_API_KEY",
tomtom_env_var: str = "TOMTOM_API_KEY",
timeout: float = 10.0,
) -> MapsClient:
"""Construct from GOOGLE_MAPS_API_KEY, wiring the TomTom backup.
The TomTom backup is wired only when TOMTOM_API_KEY is set; absent
it, the client runs Google-only and a Google failure propagates.
"""
api_key = os.environ.get(env_var, "")
if not api_key:
raise ValueError(
f"MapsClient.from_env: ${env_var} is unset — generate a Distance Matrix "
f"API key at https://console.cloud.google.com/apis/credentials and add "
f"it to OneCLI vault"
)
tomtom = None
if os.environ.get(tomtom_env_var):
tomtom = TomTomClient.from_env(env_var=tomtom_env_var, timeout=timeout)
return cls(api_key, timeout=timeout, tomtom=tomtom)
def travel_time(self, origin: str, destination: str) -> TravelTime:
"""Return travel time for one origin→destination pair.
Tries Google first; on any Google `MapsError` or transport
failure, falls back to TomTom when a backup is configured. Per
`coding-policy: error-handling` "Graceful Fallback": when both
providers fail, raises `MapsError("ALL_PROVIDERS_FAILED", ...)`
naming what each provider reported.
Raises:
ValueError: if either argument is empty
MapsError: on Google failure with no backup, or both failing
urllib.error.HTTPError: on Google transport failure with no backup
"""
if not origin or not destination:
raise ValueError("travel_time: origin and destination are required")
try:
return self._google_travel_time(origin, destination)
except (MapsError, urllib.error.URLError) as google_err:
if self._tomtom is None:
raise
try:
return self._tomtom.travel_time(origin, destination)
except (MapsError, urllib.error.URLError) as tomtom_err:
raise MapsError(
"ALL_PROVIDERS_FAILED",
f"Google failed ({google_err}); TomTom backup failed ({tomtom_err})",
) from tomtom_err
def _google_travel_time(self, origin: str, destination: str) -> TravelTime:
"""Query the Google Distance Matrix API for a single pair.
Uses `departure_time=now` + `traffic_model=best_guess` so the
response includes a current-traffic estimate.
"""
payload = _get_json(self._build_url(origin, destination), self._timeout)
return self._parse(payload)
def _build_url(self, origin: str, destination: str) -> str:
query = urllib.parse.urlencode(
{
"origins": origin,
"destinations": destination,
"departure_time": "now",
"traffic_model": "best_guess",
"key": self._api_key,
}
)
return f"{_GOOGLE_ENDPOINT}?{query}"
def _parse(self, payload: dict) -> TravelTime:
"""Decode the Distance Matrix response into a TravelTime."""
top_status = payload.get("status", "UNKNOWN")
if top_status != "OK":
raise MapsError(
top_status,
payload.get("error_message", "Distance Matrix request failed"),
)
origins = payload.get("origin_addresses") or [""]
destinations = payload.get("destination_addresses") or [""]
rows = payload.get("rows") or []
if not rows or not rows[0].get("elements"):
raise MapsError(
"MALFORMED_RESPONSE",
"Distance Matrix response had no elements",
)
element = rows[0]["elements"][0]
element_status = element.get("status", "UNKNOWN")
if element_status != "OK":
raise MapsError(
element_status,
f"Element status from {origins[0]} → {destinations[0]}",
)
duration = element.get("duration", {}).get("value")
distance = element.get("distance", {}).get("value")
if duration is None or distance is None:
raise MapsError(
"MALFORMED_RESPONSE",
"Distance Matrix element missing duration or distance",
)
in_traffic = None
in_traffic_block = element.get("duration_in_traffic")
if in_traffic_block and "value" in in_traffic_block:
in_traffic = int(in_traffic_block["value"])
return TravelTime(
duration_seconds=int(duration),
in_traffic_seconds=in_traffic,
traffic_factor=_traffic_factor(int(duration), in_traffic),
distance_meters=int(distance),
origin_resolved=origins[0],
destination_resolved=destinations[0],
source="google",
)skills
check-travel-bookings
flight-assist
nightly-travel-sync
sync-tripit