Skip to content

Commit 3e746e7

Browse files
committed
initial commit
1 parent d35844f commit 3e746e7

1 file changed

Lines changed: 118 additions & 0 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2025-2026 Dimensional Inc.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""
17+
Phone Teleop FastAPI Server.
18+
19+
Replaces the Deno WebSocket-to-LCM bridge with a Python FastAPI server.
20+
Extends RobotWebInterface to serve the phone teleop web app and forward
21+
WebSocket binary LCM packets to the LCM network via UDP multicast.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
from pathlib import Path
27+
import socket
28+
import struct
29+
30+
from fastapi import WebSocket, WebSocketDisconnect
31+
from fastapi.responses import HTMLResponse
32+
from fastapi.staticfiles import StaticFiles
33+
34+
from dimos.utils.logging_config import setup_logger
35+
from dimos.web.robot_web_interface import RobotWebInterface
36+
37+
logger = setup_logger()
38+
39+
# LCM default multicast group and port
40+
LCM_MULTICAST_GROUP = "239.255.76.67"
41+
LCM_MULTICAST_PORT = 7667
42+
43+
STATIC_DIR = Path(__file__).parent / "static"
44+
45+
46+
class PhoneTeleopServer(RobotWebInterface):
47+
"""Phone teleoperation server built on RobotWebInterface.
48+
49+
Adds a WebSocket endpoint that bridges binary LCM packets from the
50+
phone browser to the LCM UDP multicast network.
51+
"""
52+
53+
def __init__(
54+
self,
55+
port: int = 8444,
56+
lcm_multicast_group: str = LCM_MULTICAST_GROUP,
57+
lcm_multicast_port: int = LCM_MULTICAST_PORT,
58+
) -> None:
59+
super().__init__(port=port)
60+
self.lcm_multicast_group = lcm_multicast_group
61+
self.lcm_multicast_port = lcm_multicast_port
62+
self._udp_sock: socket.socket | None = None
63+
64+
self._setup_teleop_routes()
65+
66+
# ------------------------------------------------------------------
67+
# LCM UDP socket
68+
# ------------------------------------------------------------------
69+
70+
def _ensure_udp_socket(self) -> socket.socket:
71+
"""Create (or reuse) a UDP socket for LCM multicast publishing."""
72+
if self._udp_sock is None:
73+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
74+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, struct.pack("b", 1))
75+
self._udp_sock = sock
76+
logger.info("LCM UDP multicast socket created")
77+
return self._udp_sock
78+
79+
def _publish_lcm_packet(self, data: bytes) -> None:
80+
"""Send a raw LCM packet to the multicast group."""
81+
sock = self._ensure_udp_socket()
82+
sock.sendto(data, (self.lcm_multicast_group, self.lcm_multicast_port))
83+
84+
# ------------------------------------------------------------------
85+
# Routes
86+
# ------------------------------------------------------------------
87+
88+
def _setup_teleop_routes(self) -> None:
89+
"""Add phone-teleop-specific routes on top of the base interface."""
90+
91+
@self.app.get("/teleop", response_class=HTMLResponse)
92+
async def teleop_index() -> HTMLResponse:
93+
index_path = STATIC_DIR / "index.html"
94+
return HTMLResponse(content=index_path.read_text())
95+
96+
if STATIC_DIR.is_dir():
97+
self.app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="teleop_static")
98+
99+
@self.app.websocket("/ws")
100+
async def websocket_endpoint(ws: WebSocket) -> None:
101+
await ws.accept()
102+
logger.info("Phone client connected")
103+
try:
104+
while True:
105+
data = await ws.receive_bytes()
106+
try:
107+
self._publish_lcm_packet(data)
108+
except Exception:
109+
logger.exception("Failed to forward LCM packet")
110+
except WebSocketDisconnect:
111+
logger.info("Phone client disconnected")
112+
except Exception:
113+
logger.exception("WebSocket error")
114+
115+
116+
if __name__ == "__main__":
117+
server = PhoneTeleopServer()
118+
server.run()

0 commit comments

Comments
 (0)