Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 26 additions & 64 deletions WebATM/bluesky_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"""

import threading
import traceback
from collections import defaultdict, deque
from collections.abc import Callable

Expand Down Expand Up @@ -147,10 +148,10 @@ def seqidx2id(seqidx):
def safe_decode(data):
"""Decode bytes to a readable string without raising.

Attempts UTF-8 decoding first and returns the result only if it consists
entirely of printable ASCII characters; otherwise falls back to ASCII
decoding, and finally to an uppercase hexadecimal representation. Non-bytes
input is converted with ``str()``.
Attempts UTF-8 decoding and returns the result only if it consists
entirely of printable ASCII characters; otherwise falls back to an
uppercase hexadecimal representation. Non-bytes input is converted with
``str()``.

Args:
data (bytes | object): Value to decode or stringify.
Expand All @@ -160,22 +161,12 @@ def safe_decode(data):
"""
if isinstance(data, bytes):
try:
# First try utf-8 decoding
decoded = data.decode("utf-8")
# Check if the decoded string contains only printable ASCII characters
if all(32 <= ord(c) <= 126 for c in decoded):
return decoded
else:
# Contains non-printable characters, use hex representation
return data.hex().upper()
except UnicodeDecodeError:
try:
# Try ASCII decoding
decoded = data.decode("ascii")
return decoded
except UnicodeDecodeError:
# Unable to decode as text, use hex representation
return data.hex().upper()
return data.hex().upper()
if all(32 <= ord(c) <= 126 for c in decoded):
return decoded
return data.hex().upper()
return str(data)


Expand Down Expand Up @@ -232,16 +223,11 @@ def emit(self, *args, **kwargs):
*args (Any): Positional arguments forwarded to each callback.
**kwargs (Any): Keyword arguments forwarded to each callback.
"""
callbacks_snapshot = self.callbacks[
:
] # Make a copy to avoid concurrency issues
for callback in callbacks_snapshot:
for callback in self.callbacks[:]:
try:
callback(*args, **kwargs)
except Exception as e:
logger.warning(f"Signal {self.name}: Error in callback {callback}: {e}")
import traceback

traceback.print_exc()


Expand Down Expand Up @@ -289,11 +275,7 @@ def emit(self, topic: str, *args, **kwargs):
callback(*args, **kwargs)
except Exception as e:
logger.warning(f"Subscriber {topic}: Error in callback {callback}: {e}")
logger.debug(f"Subscriber {topic}: Error type: {type(e).__name__}")
logger.debug(f"Subscriber {topic}: Args: {args}")
logger.debug(f"Subscriber {topic}: Kwargs: {kwargs}")
import traceback

logger.debug(f"Subscriber {topic}: Args: {args} Kwargs: {kwargs}")
traceback.print_exc()


Expand Down Expand Up @@ -711,31 +693,21 @@ def _process_data_message(self, msg):
else:
self.subscriber.emit(topic, data) # Pass as single argument
elif topic == "ECHO":
# ECHO expects: text, flags, sender_id (can be called with varying args)
# Always include sender_id from message header to identify which node sent the echo
if isinstance(data, (list, tuple)):
# Ensure we always pass sender_id from message header
if len(data) >= 3:
# Data already contains [text, flags, sender_id]
self.subscriber.emit(topic, *data)
elif len(data) == 2:
# Data is [text, flags] - add sender_id from header
self.subscriber.emit(topic, data[0], data[1], sender_id)
elif len(data) == 1:
# Data is [text] - add default flags and sender_id from header
self.subscriber.emit(topic, data[0], 0, sender_id)
else:
# Empty list - send empty text with sender_id from header
self.subscriber.emit(topic, "", 0, sender_id)
elif isinstance(data, dict):
# ECHO handlers expect (text, flags, sender_id). Normalize
# the payload — [text], [text, flags], [text, flags,
# sender_id], a dict, or a bare string — filling missing
# flags with 0 and the sender from the message header.
if isinstance(data, dict):
text = data.get("text", "")
flags = data.get("flags", 0)
# Use sender_id from data if available, otherwise from message header
data_sender_id = data.get("sender_id", sender_id)
self.subscriber.emit(topic, text, flags, data_sender_id)
echo_sender = data.get("sender_id", sender_id)
else:
# Simple string or other data - add defaults and sender_id from header
self.subscriber.emit(topic, str(data), 0, sender_id)
if not isinstance(data, (list, tuple)):
data = [str(data)]
text = data[0] if len(data) > 0 else ""
flags = data[1] if len(data) > 1 else 0
echo_sender = data[2] if len(data) > 2 else sender_id
self.subscriber.emit(topic, text, flags, echo_sender)
elif topic == "STATECHANGE":
# STATECHANGE follows BlueSky's shared-state format:
# [action_type, {"simstate": <int>, ...}]
Expand Down Expand Up @@ -973,20 +945,10 @@ def delnode(self, node_id):
return self.send("DELNODE", node_id, target_server)

def on_node_added_request_data(self, node_id):
"""When a new node is announced, request the initial/current state of all
subscribed shared states."""
logger.info("A new node has been added! request topics")

# TODO: fix request
# Request all BlueSky topics we want to receive add #STACK
# topics = ['RESET', 'REQUEST', 'PLOT', 'SHOWDIALOG', 'SIMINFO',
# 'SIMSETTINGS', 'TRAILS', 'ROUTEDATA', 'ACDATA', 'DEFWPT',
# 'POLY', 'STACKCMDS']

"""When a new node is announced, request the current state of the
subscribed shared states (shapes and the command dictionary)."""
topics = ["POLY", "STACKCMDS"]

logger.debug(
logger.info(
f"Requesting topics {topics} from all nodes (triggered by new node {safe_decode(node_id)})"
)
self.send("REQUEST", topics)
self.send("REQUEST", topics)
4 changes: 0 additions & 4 deletions WebATM/proxy/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,6 @@ def _on_actnode_changed(self, node_id):
"""Callback when active node changes."""
return self.node_mgr._on_actnode_changed(node_id)

def _emit_active_node_poly_data(self):
"""Emit POLY and POLYLINE data for the currently active node."""
return self.node_mgr._emit_active_node_poly_data()

def _on_node_added(self, node_id):
"""Callback when a new node is discovered."""
return self.node_mgr._on_node_added(node_id)
Expand Down
4 changes: 3 additions & 1 deletion WebATM/proxy/handlers/shapes.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,9 @@ def on_poly_received(data, *args, **kwargs):
# sets (not just this message's shapes).
active_node_id = proxy._get_safe_active_node()
if sender_id and active_node_id and sender_id == active_node_id:
if proxy.socketio:
# Same guard as every other emit site; a client connecting later
# gets the stored sets from its initial_data/connect envelopes.
if proxy.socketio and proxy.connected_clients > 0:
proxy.socketio.emit(
"poly", proxy.poly_data_by_node.get(sender_id, {"polys": {}})
)
Expand Down
78 changes: 43 additions & 35 deletions WebATM/server/socket_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,27 @@
logger = get_logger()


def _tracked_binary_node_id(proxy, node_id):
"""Map a frontend hex node ID to the tracked node's binary ID.

Args:
proxy (BlueSkyProxy): The current BlueSky proxy.
node_id (str): Hex-string node ID as sent by the frontend.

Returns:
bytes | None: The original binary node ID, or None when the node is
not tracked.
"""
node_data = proxy.tracked_nodes.get(node_id)
if node_data is None:
logger.debug(
f"Could not find node ID for: {node_id} "
f"(available: {list(proxy.tracked_nodes.keys())})"
)
return None
return node_data.get("node_id")


def register_socket_handlers(socketio, session_manager):
"""Register all Socket.IO event handlers.

Expand All @@ -29,8 +50,8 @@ def on_connect(auth):
"""Handle a new web client connection (``connect`` event).

Creates and tracks a session, increments the connected-client
counter, and sends the ``initial_data`` snapshot and the active
node's shapes.
counter, and sends this client the ``initial_data`` snapshot and the
active node's shape envelopes.

Args:
auth: Socket.IO auth payload (unused).
Expand All @@ -52,23 +73,26 @@ def on_connect(auth):
)

try:
emit("initial_data", current_app.bluesky_proxy.get_current_data())
# Shapes created before this client connected. node_info is NOT
# sent here: it would show "Connected (No Data)" before the user
snapshot = current_app.bluesky_proxy.get_current_data()
emit("initial_data", snapshot)
# Complete shape envelopes for this client only. A reconnecting
# browser needs them to prune shapes deleted while it was away
# (initial_data only ever adds shapes); other clients are already
# in sync, so this must not broadcast. node_info is NOT sent
# here: it would show "Connected (No Data)" before the user
# connects; it flows naturally once data arrives.
current_app.bluesky_proxy._emit_active_node_poly_data()
emit("poly", snapshot["poly_data"] or {"polys": {}})
emit("polyline", snapshot["polyline_data"] or {"polys": {}})
except Exception as e:
logger.info(f"Error sending initial data to {session_id}: {e}")

@socketio.on("disconnect")
def on_disconnect(reason):
"""Handle a web client disconnect (``disconnect`` event).

Removes the session from the session manager and decrements the
connected-client counter. The counter is only decremented for
connections whose session was actually tracked, keeping it
symmetric with ``on_connect`` (a connection rejected there never
incremented it).
Removes the session and decrements the connected-client counter —
but only for connections whose session was actually tracked, keeping
the counter symmetric with ``on_connect``.

Args:
reason: Disconnect reason supplied by Flask-SocketIO.
Expand Down Expand Up @@ -107,26 +131,17 @@ def on_command(data):
def on_set_active_node(data):
"""Switch the active simulation node (``set_active_node`` event).

The frontend sends hex-string node IDs; the handler looks up the
original binary ID in the proxy's tracked nodes before delegating to
``actnode``.

Args:
data (dict): Payload with the hex-string ``node_id``.
"""
node_id = (data or {}).get("node_id")
if not node_id:
return

node_data = current_app.bluesky_proxy.tracked_nodes.get(node_id)
if node_data is None:
logger.debug(
f"Could not find node ID for: {node_id} "
f"(available: {list(current_app.bluesky_proxy.tracked_nodes.keys())})"
)
binary_node_id = _tracked_binary_node_id(current_app.bluesky_proxy, node_id)
if binary_node_id is None:
return

binary_node_id = node_data.get("node_id")
logger.info(f"Setting active node to: {node_id} (binary: {binary_node_id})")
try:
current_app.bluesky_proxy.actnode(binary_node_id)
Expand Down Expand Up @@ -158,19 +173,17 @@ def on_add_nodes(data):
if server_id and isinstance(server_id, str):
server_id = server_id.encode()
current_app.bluesky_proxy.addnodes(count, server_id=server_id)
logger.info(f"Added {count} nodes to server {server_id}")
logger.info(f"Requested {count} new node(s) on server {server_id}")
except Exception as e:
logger.info(f"Error adding nodes: {e}")

@socketio.on("del_node")
def on_del_node(data):
"""Terminate a single simulation node (``del_node`` event).

The frontend sends hex-string node IDs; the handler looks up the
original binary ID in the proxy's tracked nodes before delegating to
``delnode``, which sends a DELNODE message to the owning server. The
node's removal flows back through the normal node-removed pipeline
(tracked-nodes cleanup, active-node failover, ``node_info`` emission).
Sends a DELNODE message to the owning server; the node's removal
flows back through the normal node-removed pipeline (tracked-nodes
cleanup, active-node failover, ``node_info`` emission).

Args:
data (dict): Payload with the hex-string ``node_id``.
Expand All @@ -179,15 +192,10 @@ def on_del_node(data):
if not node_id:
return

node_data = current_app.bluesky_proxy.tracked_nodes.get(node_id)
if node_data is None:
logger.debug(
f"Could not find node ID for: {node_id} "
f"(available: {list(current_app.bluesky_proxy.tracked_nodes.keys())})"
)
binary_node_id = _tracked_binary_node_id(current_app.bluesky_proxy, node_id)
if binary_node_id is None:
return

binary_node_id = node_data.get("node_id")
logger.info(
f"Requesting node termination: {node_id} (binary: {binary_node_id})"
)
Expand Down
Loading