haptic_skin.routing
Routing — turn the trip into a list of waypoints the navigator can follow.
A Waypoint is a maneuver point with the bearing to follow after it.
Sources:
- Google Directions API (live, needs an API key) —
fetch_route_google() - a saved Directions JSON / a simple waypoint JSON —
load_route()
Both produce a Route. Keeping a file loader means the whole nav stack
is testable offline (and gives us the indoor "replay" fallback for the demo).
1"""Routing — turn the trip into a list of waypoints the navigator can follow. 2 3A :class:`Waypoint` is a maneuver point with the bearing to follow *after* it. 4Sources: 5 * Google Directions API (live, needs an API key) — :func:`fetch_route_google` 6 * a saved Directions JSON / a simple waypoint JSON — :func:`load_route` 7 8Both produce a :class:`Route`. Keeping a file loader means the whole nav stack 9is testable offline (and gives us the indoor "replay" fallback for the demo). 10""" 11 12from __future__ import annotations 13 14import json 15import re 16from dataclasses import dataclass 17from typing import List 18 19from .geo import LatLon, bearing_deg 20 21_TAG_RE = re.compile(r"<[^>]+>") 22 23 24@dataclass 25class Waypoint: 26 lat: float 27 lon: float 28 next_bearing: float # compass bearing to follow after this point 29 instruction: str = "" 30 31 @property 32 def point(self) -> LatLon: 33 return (self.lat, self.lon) 34 35 36@dataclass 37class Route: 38 waypoints: List[Waypoint] 39 40 def __len__(self) -> int: 41 return len(self.waypoints) 42 43 def to_json(self) -> str: 44 return json.dumps( 45 {"waypoints": [vars(w) for w in self.waypoints]}, indent=2 46 ) 47 48 49def _strip_html(text: str) -> str: 50 return _TAG_RE.sub("", text or "").strip() 51 52 53def route_from_directions(payload: dict) -> Route: 54 """Build a Route from a Google Directions API JSON response.""" 55 routes = payload.get("routes") or [] 56 if not routes: 57 raise ValueError("no route in Directions payload") 58 steps = routes[0]["legs"][0]["steps"] 59 wps: List[Waypoint] = [] 60 for step in steps: 61 start = (step["start_location"]["lat"], step["start_location"]["lng"]) 62 end = (step["end_location"]["lat"], step["end_location"]["lng"]) 63 wps.append( 64 Waypoint(start[0], start[1], bearing_deg(start, end), 65 _strip_html(step.get("html_instructions", ""))) 66 ) 67 # final destination as a terminal waypoint (bearing irrelevant) 68 last_end = steps[-1]["end_location"] 69 wps.append(Waypoint(last_end["lat"], last_end["lng"], 0.0, "Arrivée")) 70 return Route(wps) 71 72 73def route_from_waypoints(payload: dict) -> Route: 74 """Build a Route from a simple ``{"waypoints": [{lat,lon,next_bearing,instruction}]}``.""" 75 return Route([Waypoint(**w) for w in payload["waypoints"]]) 76 77 78def load_route(path: str) -> Route: 79 """Load a route from JSON — accepts both formats above.""" 80 with open(path, "r", encoding="utf-8") as fh: 81 data = json.load(fh) 82 if "routes" in data: 83 return route_from_directions(data) 84 return route_from_waypoints(data) 85 86 87def fetch_route_google(origin: LatLon, dest: LatLon, api_key: str, 88 mode: str = "walking") -> Route: 89 """Call the Google Directions API and return a Route (uses stdlib only).""" 90 import urllib.parse 91 import urllib.request 92 93 params = urllib.parse.urlencode({ 94 "origin": f"{origin[0]},{origin[1]}", 95 "destination": f"{dest[0]},{dest[1]}", 96 "mode": mode, 97 "key": api_key, 98 }) 99 url = f"https://maps.googleapis.com/maps/api/directions/json?{params}" 100 with urllib.request.urlopen(url, timeout=10) as resp: # noqa: S310 101 payload = json.loads(resp.read().decode("utf-8")) 102 if payload.get("status") != "OK": 103 raise RuntimeError(f"Directions error: {payload.get('status')}") 104 return route_from_directions(payload) 105 106 107def route_to_trace(route: Route, step_m: float = 3.0) -> List[dict]: 108 """Densify a route into a list of fixes ``{lat, lon, heading}``. 109 110 Used offline to feed the navigator without a browser/GPS (tests + replay). 111 """ 112 from .geo import haversine_m 113 114 fixes: List[dict] = [] 115 pts = route.waypoints 116 if not pts: 117 return fixes 118 for i in range(len(pts) - 1): 119 a, b = pts[i].point, pts[i + 1].point 120 seg = haversine_m(a, b) 121 heading = bearing_deg(a, b) 122 n = max(1, int(seg / step_m)) 123 for k in range(n): 124 t = k / n 125 fixes.append({ 126 "lat": a[0] + (b[0] - a[0]) * t, 127 "lon": a[1] + (b[1] - a[1]) * t, 128 "heading": heading, 129 }) 130 last = pts[-1].point 131 fixes.append({"lat": last[0], "lon": last[1], "heading": 0.0}) 132 return fixes
@dataclass
class
Waypoint:
@dataclass
class
Route:
37@dataclass 38class Route: 39 waypoints: List[Waypoint] 40 41 def __len__(self) -> int: 42 return len(self.waypoints) 43 44 def to_json(self) -> str: 45 return json.dumps( 46 {"waypoints": [vars(w) for w in self.waypoints]}, indent=2 47 )
Route(waypoints: List[Waypoint])
waypoints: List[Waypoint]
54def route_from_directions(payload: dict) -> Route: 55 """Build a Route from a Google Directions API JSON response.""" 56 routes = payload.get("routes") or [] 57 if not routes: 58 raise ValueError("no route in Directions payload") 59 steps = routes[0]["legs"][0]["steps"] 60 wps: List[Waypoint] = [] 61 for step in steps: 62 start = (step["start_location"]["lat"], step["start_location"]["lng"]) 63 end = (step["end_location"]["lat"], step["end_location"]["lng"]) 64 wps.append( 65 Waypoint(start[0], start[1], bearing_deg(start, end), 66 _strip_html(step.get("html_instructions", ""))) 67 ) 68 # final destination as a terminal waypoint (bearing irrelevant) 69 last_end = steps[-1]["end_location"] 70 wps.append(Waypoint(last_end["lat"], last_end["lng"], 0.0, "Arrivée")) 71 return Route(wps)
Build a Route from a Google Directions API JSON response.
74def route_from_waypoints(payload: dict) -> Route: 75 """Build a Route from a simple ``{"waypoints": [{lat,lon,next_bearing,instruction}]}``.""" 76 return Route([Waypoint(**w) for w in payload["waypoints"]])
Build a Route from a simple {"waypoints": [{lat,lon,next_bearing,instruction}]}.
79def load_route(path: str) -> Route: 80 """Load a route from JSON — accepts both formats above.""" 81 with open(path, "r", encoding="utf-8") as fh: 82 data = json.load(fh) 83 if "routes" in data: 84 return route_from_directions(data) 85 return route_from_waypoints(data)
Load a route from JSON — accepts both formats above.
def
fetch_route_google( origin: Tuple[float, float], dest: Tuple[float, float], api_key: str, mode: str = 'walking') -> Route:
88def fetch_route_google(origin: LatLon, dest: LatLon, api_key: str, 89 mode: str = "walking") -> Route: 90 """Call the Google Directions API and return a Route (uses stdlib only).""" 91 import urllib.parse 92 import urllib.request 93 94 params = urllib.parse.urlencode({ 95 "origin": f"{origin[0]},{origin[1]}", 96 "destination": f"{dest[0]},{dest[1]}", 97 "mode": mode, 98 "key": api_key, 99 }) 100 url = f"https://maps.googleapis.com/maps/api/directions/json?{params}" 101 with urllib.request.urlopen(url, timeout=10) as resp: # noqa: S310 102 payload = json.loads(resp.read().decode("utf-8")) 103 if payload.get("status") != "OK": 104 raise RuntimeError(f"Directions error: {payload.get('status')}") 105 return route_from_directions(payload)
Call the Google Directions API and return a Route (uses stdlib only).
108def route_to_trace(route: Route, step_m: float = 3.0) -> List[dict]: 109 """Densify a route into a list of fixes ``{lat, lon, heading}``. 110 111 Used offline to feed the navigator without a browser/GPS (tests + replay). 112 """ 113 from .geo import haversine_m 114 115 fixes: List[dict] = [] 116 pts = route.waypoints 117 if not pts: 118 return fixes 119 for i in range(len(pts) - 1): 120 a, b = pts[i].point, pts[i + 1].point 121 seg = haversine_m(a, b) 122 heading = bearing_deg(a, b) 123 n = max(1, int(seg / step_m)) 124 for k in range(n): 125 t = k / n 126 fixes.append({ 127 "lat": a[0] + (b[0] - a[0]) * t, 128 "lon": a[1] + (b[1] - a[1]) * t, 129 "heading": heading, 130 }) 131 last = pts[-1].point 132 fixes.append({"lat": last[0], "lon": last[1], "heading": 0.0}) 133 return fixes
Densify a route into a list of fixes {lat, lon, heading}.
Used offline to feed the navigator without a browser/GPS (tests + replay).