You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

201 lines
5.7 KiB

extends Node
## WebSocket client that connects to teleop_server.py on the robot.
## Sends body tracking data as JSON, receives JPEG webcam frames as binary.
##
## Protocol:
## Client → Server: JSON text messages with tracking data
## Server → Client: Binary messages with JPEG webcam frames
##
## No SSL required — raw WebSocket over local network.
## Emitted when a JPEG webcam frame is received from the server.
## Camera IDs: 0=head, 1=left_wrist, 2=right_wrist
signal webcam_frame_received(jpeg_bytes: PackedByteArray) ## Legacy (head only)
signal webcam_frame_head(jpeg_bytes: PackedByteArray)
signal webcam_frame_left(jpeg_bytes: PackedByteArray)
signal webcam_frame_right(jpeg_bytes: PackedByteArray)
## Emitted when connection state changes.
signal connection_state_changed(connected: bool)
## Server address — robot's IP and teleop_server.py port
@export var server_host: String = "10.0.0.64"
@export var server_port: int = 8765
@export var auto_connect: bool = true
## Reconnection settings
@export var reconnect_delay_sec: float = 2.0
@export var max_reconnect_attempts: int = 0 # 0 = unlimited
## Connection state
var ws := WebSocketPeer.new()
var is_connected: bool = false
var _reconnect_timer: float = 0.0
var _reconnect_attempts: int = 0
var _was_connected: bool = false
var _pending_data: Array = []
## Stats
var messages_sent: int = 0
var messages_received: int = 0
var bytes_sent: int = 0
var bytes_received: int = 0
var last_send_time: int = 0
var last_receive_time: int = 0
func _ready() -> void:
if auto_connect:
connect_to_server()
func _process(delta: float) -> void:
ws.poll()
var state := ws.get_ready_state()
match state:
WebSocketPeer.STATE_OPEN:
if not is_connected:
is_connected = true
_was_connected = true
_reconnect_attempts = 0
print("[TeleopClient] Connected to ws://%s:%d" % [server_host, server_port])
connection_state_changed.emit(true)
# Process incoming messages
while ws.get_available_packet_count() > 0:
var packet := ws.get_packet()
bytes_received += packet.size()
messages_received += 1
last_receive_time = Time.get_ticks_msec()
# Check if this is a binary (JPEG) or text message
if ws.was_string_packet():
_handle_text_message(packet.get_string_from_utf8())
else:
# Binary = 1 byte camera_id + JPEG data
if packet.size() > 1:
var cam_id := packet[0]
var jpeg_data := packet.slice(1)
match cam_id:
0:
webcam_frame_head.emit(jpeg_data)
webcam_frame_received.emit(jpeg_data)
1:
webcam_frame_left.emit(jpeg_data)
2:
webcam_frame_right.emit(jpeg_data)
_:
webcam_frame_received.emit(packet)
# Send any pending tracking data
for data in _pending_data:
_send_json(data)
_pending_data.clear()
WebSocketPeer.STATE_CLOSING:
pass # Wait for close to complete
WebSocketPeer.STATE_CLOSED:
if is_connected or _was_connected:
var code := ws.get_close_code()
var reason := ws.get_close_reason()
print("[TeleopClient] Disconnected (code=%d, reason=%s)" % [code, reason])
is_connected = false
connection_state_changed.emit(false)
# Auto-reconnect
if auto_connect and _was_connected:
_reconnect_timer -= delta
if _reconnect_timer <= 0:
_reconnect_timer = reconnect_delay_sec
_attempt_reconnect()
WebSocketPeer.STATE_CONNECTING:
pass # Still connecting
func connect_to_server() -> void:
var url := "ws://%s:%d" % [server_host, server_port]
print("[TeleopClient] Connecting to %s..." % url)
var err := ws.connect_to_url(url)
if err != OK:
printerr("[TeleopClient] Failed to initiate connection: ", err)
func disconnect_from_server() -> void:
_was_connected = false
ws.close()
func _attempt_reconnect() -> void:
if max_reconnect_attempts > 0:
_reconnect_attempts += 1
if _reconnect_attempts > max_reconnect_attempts:
print("[TeleopClient] Max reconnect attempts reached, giving up")
_was_connected = false
return
print("[TeleopClient] Reconnect attempt %d/%d..." % [_reconnect_attempts, max_reconnect_attempts])
else:
_reconnect_attempts += 1
if _reconnect_attempts % 5 == 1: # Log every 5th attempt to avoid spam
print("[TeleopClient] Reconnect attempt %d..." % _reconnect_attempts)
connect_to_server()
## Called by body_tracker via signal when new tracking data is ready.
func _on_tracking_data(data: Dictionary) -> void:
if is_connected:
_send_json(data)
# Don't buffer if not connected — tracking data is perishable
## Send a command message to the server (e.g. "recalibrate").
func send_command(command: String) -> void:
if is_connected:
_send_json({"type": command})
func _send_json(data: Dictionary) -> void:
var json_str := JSON.stringify(data)
var err := ws.send_text(json_str)
if err == OK:
messages_sent += 1
bytes_sent += json_str.length()
last_send_time = Time.get_ticks_msec()
else:
printerr("[TeleopClient] Failed to send: ", err)
func _handle_text_message(text: String) -> void:
# Server may send JSON status/config messages
var json := JSON.new()
var err := json.parse(text)
if err != OK:
printerr("[TeleopClient] Invalid JSON from server: ", text.left(100))
return
var data: Dictionary = json.data
var msg_type: String = data.get("type", "")
match msg_type:
"config":
print("[TeleopClient] Server config: ", data)
"status":
print("[TeleopClient] Server status: ", data.get("message", ""))
_:
print("[TeleopClient] Unknown message type: ", msg_type)
## Get connection statistics as a dictionary
func get_stats() -> Dictionary:
return {
"connected": is_connected,
"messages_sent": messages_sent,
"messages_received": messages_received,
"bytes_sent": bytes_sent,
"bytes_received": bytes_received,
"reconnect_attempts": _reconnect_attempts,
}