haptic_skin.bridge
Bridge between a fix source (Street View browser, GPS, replay) and the necklace.
Entry points:
serve()— async WebSocket server. The browser streams JSON fixes{lat, lon, heading}; we feed them to a~haptic_skin.nav.Navigatorand drive the necklace, echoing the live motor vector + state back for the on-screen mirror. A 30 Hz applier loop decouples fix rate from pulse cadence.replay()— synchronous, no deps: feed a route's own trace through the navigator with real timing. The indoor "no internet / no browser" fallback.
The per-connection logic (handle_connection()) is module-level so it can
be tested with a fake websocket, without the websockets dependency.
1"""Bridge between a fix source (Street View browser, GPS, replay) and the necklace. 2 3Entry points: 4 * :func:`serve` — async WebSocket server. The browser streams JSON fixes 5 ``{lat, lon, heading}``; we feed them to a :class:`~haptic_skin.nav.Navigator` 6 and drive the necklace, echoing the live motor vector + state back for the 7 on-screen mirror. A 30 Hz applier loop decouples fix rate from pulse cadence. 8 * :func:`replay` — synchronous, no deps: feed a route's own trace through the 9 navigator with real timing. The indoor "no internet / no browser" fallback. 10 11The per-connection logic (:func:`handle_connection`) is module-level so it can 12be tested with a fake websocket, without the ``websockets`` dependency. 13""" 14 15from __future__ import annotations 16 17import asyncio 18import json 19import time 20from typing import Optional 21 22from . import protocol 23from .nav import Navigator 24from .necklace import Necklace 25from .routing import Route, route_from_waypoints, route_to_trace 26 27APPLIER_HZ = 30.0 28TEST_STEP_S = 0.18 # dwell per motor during the self-test 29 30 31# -------------------------------------------------------------------------- 32# Offline replay (sync) 33# -------------------------------------------------------------------------- 34def replay(necklace: Necklace, route: Route, *, speed: float = 1.5, 35 step_m: float = 3.0, sleep=time.sleep, clock=time.monotonic, 36 on_state=None, on_frame=None) -> None: 37 """Walk the route at ``speed`` m/s, driving the necklace. Blocking. 38 39 ``on_state(NavState)`` is called whenever the navigation *band* changes. 40 ``on_frame(vec, NavState, t)`` is called on every applier tick with the live 41 motor vector — used by the demo to render a per-frame compass view. 42 """ 43 nav = Navigator(route) 44 trace = route_to_trace(route, step_m=step_m) 45 if not trace: 46 return 47 seg_time = step_m / max(0.1, speed) 48 start = clock() 49 last_band = None 50 fix_i = 0 51 52 def _drive(now: float) -> None: 53 vec = nav.vector(now) 54 necklace.set_vector(vec) 55 if on_frame: 56 on_frame(vec, nav.state, now) 57 58 try: 59 while fix_i < len(trace): 60 now = clock() 61 target_i = min(len(trace) - 1, int((now - start) / seg_time)) 62 while fix_i <= target_i: 63 st = nav.update(trace[fix_i]) 64 if on_state and st.band != last_band: 65 on_state(st) 66 last_band = st.band 67 fix_i += 1 68 _drive(now) 69 if nav.state.finished and fix_i >= len(trace): 70 break 71 sleep(1.0 / APPLIER_HZ) 72 end = clock() + 1.5 73 while clock() < end: 74 _drive(clock()) 75 sleep(1.0 / APPLIER_HZ) 76 finally: 77 necklace.stop() 78 79 80# -------------------------------------------------------------------------- 81# WebSocket connection logic (async, testable with a fake ws) 82# -------------------------------------------------------------------------- 83def state_frame(state, vec) -> dict: 84 """The JSON frame streamed to the browser (live motor mirror + nav state).""" 85 return { 86 "motors": list(vec), 87 "band": state.band, 88 "bucket": state.bucket, 89 "distance_m": round(state.distance_m, 1), 90 "instruction": state.instruction, 91 "step": state.step_index, 92 "total": state.total_steps, 93 "finished": state.finished, 94 } 95 96 97async def send_json(ws, obj) -> bool: 98 try: 99 await ws.send(json.dumps(obj)) 100 return True 101 except Exception: 102 return False 103 104 105async def motor_self_test(necklace: Necklace, ws, st: dict) -> None: 106 """Light each motor in turn — verifies the page -> bridge -> necklace chain.""" 107 st["testing"] = True 108 n = protocol.NUM_MOTORS 109 for i in range(n): 110 vec = [0] * n 111 vec[i] = 200 112 necklace.set_vector(vec) 113 await send_json(ws, {"motors": vec, "band": "test", "bucket": i, 114 "distance_m": 0, "instruction": f"Test moteur {i}", 115 "step": 0, "total": 0, "finished": False}) 116 await asyncio.sleep(TEST_STEP_S) 117 necklace.set_vector([0] * n) 118 await send_json(ws, {"motors": [0] * n, "band": "silent", "bucket": 0, 119 "distance_m": 0, "instruction": "Test OK", 120 "step": 0, "total": 0, "finished": False}) 121 st["testing"] = False 122 123 124async def _applier(necklace: Necklace, ws, st: dict, stop: asyncio.Event) -> None: 125 """Single sender: drive the necklace AND stream the live frame to the browser.""" 126 loop = asyncio.get_running_loop() 127 t0 = loop.time() 128 while not stop.is_set(): 129 nav = st["nav"] 130 if nav is not None and not st["testing"]: 131 vec = nav.vector(loop.time() - t0) 132 necklace.set_vector(vec) 133 if not await send_json(ws, state_frame(nav.state, vec)): 134 break 135 await asyncio.sleep(1.0 / APPLIER_HZ) 136 137 138async def handle_connection(necklace: Necklace, ws, route: Optional[Route] = None) -> None: 139 """Handle one client: messages drive the navigator, the applier streams back.""" 140 st = {"nav": Navigator(route) if route else None, "testing": False} 141 stop = asyncio.Event() 142 applier_task = asyncio.create_task(_applier(necklace, ws, st, stop)) 143 try: 144 async for message in ws: 145 msg = json.loads(message) 146 if msg.get("test"): # motor self-test 147 await motor_self_test(necklace, ws, st) 148 elif "waypoints" in msg: # route handshake 149 st["nav"] = Navigator(route_from_waypoints(msg)) 150 elif st["nav"] is not None: # a fix 151 st["nav"].update(msg) 152 finally: 153 stop.set() 154 applier_task.cancel() 155 necklace.stop() 156 157 158async def serve(necklace: Necklace, route: Optional[Route] = None, *, 159 host: str = "localhost", port: int = 8765) -> None: 160 """Run the WebSocket bridge until cancelled (Ctrl-C).""" 161 import logging 162 163 try: 164 import websockets # type: ignore 165 except ImportError as exc: # pragma: no cover 166 raise RuntimeError("`pip install websockets` to run the bridge") from exc 167 168 # hush noisy tracebacks from non-WebSocket clients hitting the port 169 logging.getLogger("websockets.server").setLevel(logging.CRITICAL) 170 171 async def handler(ws): 172 await handle_connection(necklace, ws, route) 173 174 where = f"{len(route)} waypoints" if route else "route supplied by browser" 175 print(f"bridge listening on ws://{host}:{port} ({where})") 176 async with websockets.serve(handler, host, port): 177 await asyncio.Future() # run forever
35def replay(necklace: Necklace, route: Route, *, speed: float = 1.5, 36 step_m: float = 3.0, sleep=time.sleep, clock=time.monotonic, 37 on_state=None, on_frame=None) -> None: 38 """Walk the route at ``speed`` m/s, driving the necklace. Blocking. 39 40 ``on_state(NavState)`` is called whenever the navigation *band* changes. 41 ``on_frame(vec, NavState, t)`` is called on every applier tick with the live 42 motor vector — used by the demo to render a per-frame compass view. 43 """ 44 nav = Navigator(route) 45 trace = route_to_trace(route, step_m=step_m) 46 if not trace: 47 return 48 seg_time = step_m / max(0.1, speed) 49 start = clock() 50 last_band = None 51 fix_i = 0 52 53 def _drive(now: float) -> None: 54 vec = nav.vector(now) 55 necklace.set_vector(vec) 56 if on_frame: 57 on_frame(vec, nav.state, now) 58 59 try: 60 while fix_i < len(trace): 61 now = clock() 62 target_i = min(len(trace) - 1, int((now - start) / seg_time)) 63 while fix_i <= target_i: 64 st = nav.update(trace[fix_i]) 65 if on_state and st.band != last_band: 66 on_state(st) 67 last_band = st.band 68 fix_i += 1 69 _drive(now) 70 if nav.state.finished and fix_i >= len(trace): 71 break 72 sleep(1.0 / APPLIER_HZ) 73 end = clock() + 1.5 74 while clock() < end: 75 _drive(clock()) 76 sleep(1.0 / APPLIER_HZ) 77 finally: 78 necklace.stop()
Walk the route at speed m/s, driving the necklace. Blocking.
on_state(NavState) is called whenever the navigation band changes.
on_frame(vec, NavState, t) is called on every applier tick with the live
motor vector — used by the demo to render a per-frame compass view.
84def state_frame(state, vec) -> dict: 85 """The JSON frame streamed to the browser (live motor mirror + nav state).""" 86 return { 87 "motors": list(vec), 88 "band": state.band, 89 "bucket": state.bucket, 90 "distance_m": round(state.distance_m, 1), 91 "instruction": state.instruction, 92 "step": state.step_index, 93 "total": state.total_steps, 94 "finished": state.finished, 95 }
The JSON frame streamed to the browser (live motor mirror + nav state).
106async def motor_self_test(necklace: Necklace, ws, st: dict) -> None: 107 """Light each motor in turn — verifies the page -> bridge -> necklace chain.""" 108 st["testing"] = True 109 n = protocol.NUM_MOTORS 110 for i in range(n): 111 vec = [0] * n 112 vec[i] = 200 113 necklace.set_vector(vec) 114 await send_json(ws, {"motors": vec, "band": "test", "bucket": i, 115 "distance_m": 0, "instruction": f"Test moteur {i}", 116 "step": 0, "total": 0, "finished": False}) 117 await asyncio.sleep(TEST_STEP_S) 118 necklace.set_vector([0] * n) 119 await send_json(ws, {"motors": [0] * n, "band": "silent", "bucket": 0, 120 "distance_m": 0, "instruction": "Test OK", 121 "step": 0, "total": 0, "finished": False}) 122 st["testing"] = False
Light each motor in turn — verifies the page -> bridge -> necklace chain.
139async def handle_connection(necklace: Necklace, ws, route: Optional[Route] = None) -> None: 140 """Handle one client: messages drive the navigator, the applier streams back.""" 141 st = {"nav": Navigator(route) if route else None, "testing": False} 142 stop = asyncio.Event() 143 applier_task = asyncio.create_task(_applier(necklace, ws, st, stop)) 144 try: 145 async for message in ws: 146 msg = json.loads(message) 147 if msg.get("test"): # motor self-test 148 await motor_self_test(necklace, ws, st) 149 elif "waypoints" in msg: # route handshake 150 st["nav"] = Navigator(route_from_waypoints(msg)) 151 elif st["nav"] is not None: # a fix 152 st["nav"].update(msg) 153 finally: 154 stop.set() 155 applier_task.cancel() 156 necklace.stop()
Handle one client: messages drive the navigator, the applier streams back.
159async def serve(necklace: Necklace, route: Optional[Route] = None, *, 160 host: str = "localhost", port: int = 8765) -> None: 161 """Run the WebSocket bridge until cancelled (Ctrl-C).""" 162 import logging 163 164 try: 165 import websockets # type: ignore 166 except ImportError as exc: # pragma: no cover 167 raise RuntimeError("`pip install websockets` to run the bridge") from exc 168 169 # hush noisy tracebacks from non-WebSocket clients hitting the port 170 logging.getLogger("websockets.server").setLevel(logging.CRITICAL) 171 172 async def handler(ws): 173 await handle_connection(necklace, ws, route) 174 175 where = f"{len(route)} waypoints" if route else "route supplied by browser" 176 print(f"bridge listening on ws://{host}:{port} ({where})") 177 async with websockets.serve(handler, host, port): 178 await asyncio.Future() # run forever
Run the WebSocket bridge until cancelled (Ctrl-C).