Initial commit: CloudView generic networked point-cloud viewer
Standalone Open3D viewer that listens on a TCP/Unix socket and renders NDJSON point-cloud streams from any producer. Decoupled server/protocol/store layers (no Open3D dependency, testable headless) plus a lazy Open3D render loop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
"""Stream-socket server that feeds a CloudStore from NDJSON producers.
|
||||
|
||||
Listens on one or more addresses (TCP and/or Unix), accepts any number of
|
||||
producers concurrently, and applies their messages to a shared store. All
|
||||
networking is on background threads; the render loop reads the store separately.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
|
||||
from .protocol import LineDecoder, parse_address
|
||||
from .store import CloudStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloudServer:
|
||||
def __init__(self, store: CloudStore, addresses: list[str]) -> None:
|
||||
self._store = store
|
||||
self._addresses = addresses
|
||||
self._listeners: list[socket.socket] = []
|
||||
self._unix_paths: list[str] = []
|
||||
self._stop = threading.Event()
|
||||
# Actual bound endpoints (resolves ephemeral ":0" ports for callers/tests).
|
||||
self.bound: list[tuple[str, str, int | None]] = []
|
||||
|
||||
def start(self) -> None:
|
||||
for addr in self._addresses:
|
||||
kind, a, b = parse_address(addr)
|
||||
if kind == "tcp":
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((a, b))
|
||||
host, port = sock.getsockname()[:2]
|
||||
self.bound.append(("tcp", host, port))
|
||||
else:
|
||||
# Replace a stale socket file from a prior run.
|
||||
if os.path.exists(a):
|
||||
os.unlink(a)
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.bind(a)
|
||||
self._unix_paths.append(a)
|
||||
self.bound.append(("unix", a, None))
|
||||
sock.listen(8)
|
||||
sock.settimeout(0.5)
|
||||
self._listeners.append(sock)
|
||||
threading.Thread(target=self._accept_loop, args=(sock, addr),
|
||||
name=f"accept[{addr}]", daemon=True).start()
|
||||
logger.info("listening on %s", addr)
|
||||
|
||||
def _accept_loop(self, listener: socket.socket, addr: str) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
conn, _ = listener.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
threading.Thread(target=self._serve_client, args=(conn, addr),
|
||||
name=f"client[{addr}]", daemon=True).start()
|
||||
|
||||
def _serve_client(self, conn: socket.socket, addr: str) -> None:
|
||||
logger.info("producer connected on %s", addr)
|
||||
conn.settimeout(1.0)
|
||||
decoder = LineDecoder()
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
data = conn.recv(65536)
|
||||
except socket.timeout:
|
||||
continue
|
||||
if not data:
|
||||
break
|
||||
for msg in decoder.feed(data):
|
||||
self._apply(msg)
|
||||
except Exception:
|
||||
logger.exception("error serving producer on %s", addr)
|
||||
finally:
|
||||
conn.close()
|
||||
logger.info("producer disconnected (%s)", addr)
|
||||
|
||||
def _apply(self, msg: dict) -> None:
|
||||
kind = msg.get("type")
|
||||
source = msg.get("source", "default")
|
||||
if kind == "points":
|
||||
self._store.add_points(source, msg.get("points", []),
|
||||
msg.get("color"))
|
||||
elif kind == "hello":
|
||||
self._store.hello(source, msg.get("name"))
|
||||
elif kind == "clear":
|
||||
self._store.clear(source)
|
||||
elif kind == "pose":
|
||||
self._store.set_pose(source, msg.get("position", [0, 0, 0]),
|
||||
msg.get("yaw"))
|
||||
else:
|
||||
logger.debug("ignoring unknown message type %r", kind)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
for sock in self._listeners:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
for path in self._unix_paths:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
Reference in New Issue
Block a user