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
21 changes: 5 additions & 16 deletions WebATM/bluesky_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -699,26 +699,15 @@ def _process_data_message(self, msg):
# Pass sender_id as additional parameter to our custom handler
self.subscriber.emit(topic, *data, sender_id=sender_id)
elif topic in ("ACDATA", "ROUTEDATA"):
# ACDATA and ROUTEDATA - handle BlueSky shared state format
# ACDATA and ROUTEDATA use the BlueSky shared-state format
# [action_type, data_dict]; record the wire action ('R',
# 'U', ...) and sender on the context, then pass handlers
# the unwrapped data dict.
if isinstance(data, (list, tuple)) and len(data) == 2:
# BlueSky shared state format: [action_type, data_dict]
action_type, actual_data = data

# Set context action for BlueSky compatibility
self.context.action = action_type
self.context.sender_id = sender_id

# Handle different action types like BlueSky does
if action_type in ("RESET", "ACTCHANGE"):
self.context.action = (
self.context.Reset
if action_type == "RESET"
else self.context.ActChange
)

self.subscriber.emit(
topic, actual_data
) # Pass the actual data dict, not the action wrapper
self.subscriber.emit(topic, actual_data)
else:
self.subscriber.emit(topic, data) # Pass as single argument
elif topic == "ECHO":
Expand Down
31 changes: 21 additions & 10 deletions WebATM/proxy/handlers/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import time

from ...logger import get_logger
from ...utils import id2str
from ...utils import empty_traffic_data, id2str
from ._base import active_proxy

logger = get_logger()
Expand All @@ -13,21 +13,21 @@ def on_reset_received(data=None, *args, sender_id=None, **kwargs):
"""Handle RESET events from the BlueSky server.

Clears the stored polygon/polyline shapes for the node that sent the
reset. Browsers display the active node only, so the map-clearing ``poly``
and ``polyline`` payloads and the ``reset`` event are emitted solely when
the resetting node is the active one — a background node's reset must not
wipe the active node's display. When the sender or active node can't be
resolved, the reset is accepted so a single-node display still works
(same fallback as the SIMINFO/ACDATA active-node filter).
reset. Browsers display the active node only, so when the resetting node
is the active one this also clears the cached aircraft and emits the
map-clearing ``acdata``/``poly``/``polyline`` payloads plus the ``reset``
event — while a background node's reset must not wipe the active node's
display. When the sender or active node can't be resolved, the reset is
accepted so a single-node display still works (same fallback as the
SIMINFO/ACDATA active-node filter).

Args:
data (Any): Optional RESET payload (unused).
*args (Any): Additional positional payload items (unused).
sender_id (bytes | str | None): Node that reset, from the message
header; bytes are converted to a hex string. The shared network
context is deliberately not consulted — it holds the sender of the
last shared-state message (usually the active node), not of this
RESET.
last shared-state message, not of this RESET.
**kwargs (Any): Additional keyword payload items (unused).
"""
proxy = active_proxy()
Expand All @@ -49,7 +49,18 @@ def on_reset_received(data=None, *args, sender_id=None, **kwargs):
is_active_node = (
active_node_id is None or sender_id is None or sender_id == active_node_id
)
if is_active_node and proxy.socketio and proxy.connected_clients > 0:
if not is_active_node:
return

# The active node's cached aircraft are gone too. Clear even with no
# browser connected: on_acdata_received only refreshes this cache
# while clients are attached, so a stale cache would otherwise be
# served verbatim in the next initial_data snapshot (ghost aircraft).
cleared = empty_traffic_data()
proxy.traffic_data = cleared

if proxy.socketio and proxy.connected_clients > 0:
proxy.socketio.emit("acdata", cleared)
proxy.socketio.emit("poly", {"polys": {}})
proxy.socketio.emit("polyline", {"polys": {}})
proxy.socketio.emit(
Expand Down
37 changes: 10 additions & 27 deletions WebATM/proxy/handlers/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import time

from ...logger import get_logger
from ...utils import empty_traffic_data, id2str, make_json_serializable, tim2txt
from ...utils import id2str, make_json_serializable, tim2txt
from ..perf import data_path_perf
from ._base import active_proxy, get_bluesky_proxy

Expand Down Expand Up @@ -107,16 +107,16 @@ def on_siminfo_received(
def on_acdata_received(data):
"""Process a BlueSky ACDATA traffic frame and emit ``acdata`` to web clients.

On a simulation reset or active-node change (detected via the BlueSky
network context), clears the cached traffic data and immediately emits an
empty ``acdata`` payload so browsers drop stale aircraft.

Hot path: the network timer delivers ACDATA at up to 50 Hz, but it is only
emitted to browsers at ``acdata_interval`` (10 Hz) and only for the active
node. ``make_json_serializable`` is the dominant per-frame cost, so it is
deferred until after the active-node filter and the emit throttle decide the
frame is actually sent. Set WEBATM_PERF=1 (WebATM.proxy.perf) to measure it.

Aircraft clearing on a simulation reset is NOT handled here: BlueSky
signals a reset on the dedicated RESET topic (events.on_reset_received),
never via an ACDATA action.

Args:
data (dict): Aircraft state arrays keyed by field (``id``, ``lat``,
``lon``, ``alt``, ``tas``, ``trk``, ``vs``, conflict counters, ...)
Expand All @@ -133,30 +133,13 @@ def on_acdata_received(data):
logger.debug("on_acdata_received ignored - reconnection not allowed")
return

# Check context action like BlueSky web client does, and resolve which
# node sent this frame (set on the shared context just before this
# synchronous dispatch) for the active-node filter below.
# Resolve which node sent this frame (set on the shared context just
# before this synchronous dispatch) for the active-node filter below.
sender_id_str = None
if proxy.bluesky_client and hasattr(proxy.bluesky_client, "context"):
ctx = proxy.bluesky_client.context
if ctx.action == ctx.Reset or ctx.action == ctx.ActChange:
# Simulation reset or active-node change: clear all aircraft.
logger.info("ACDATA reset/actchange detected - clearing aircraft data")
cleared = empty_traffic_data()
proxy.traffic_data = cleared

# Emit cleared data immediately
if proxy.socketio and proxy.connected_clients > 0:
try:
proxy.socketio.emit("acdata", cleared)
logger.debug(
f"Emitted cleared ACDATA to {proxy.connected_clients} web clients"
)
except Exception as e:
logger.error(f"Error emitting cleared ACDATA: {e}")
return

sender_id_str = id2str(getattr(ctx, "sender_id", None))
sender_id_str = id2str(
getattr(proxy.bluesky_client.context, "sender_id", None)
)

# Any ACDATA from any node proves the link to BlueSky is alive: update
# liveness before filtering so a background node's traffic still counts.
Expand Down
11 changes: 9 additions & 2 deletions WebATM/proxy/managers/node_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from ...bluesky_client import safe_decode, seqid2idx, seqidx2id
from ...logger import get_logger
from ...utils import id2str
from ...utils import empty_traffic_data, id2str

logger = get_logger()

Expand Down Expand Up @@ -49,7 +49,14 @@ def _get_safe_active_node(self):
def _on_actnode_changed(self, node_id):
"""Callback when active node changes."""
if self.proxy.running:
# Emit immediately to update web interface
# The cached traffic belongs to the previous node; drop it (and
# clear browsers) so it can't be re-served while the new node's
# ACDATA stream spins up. Same idea as the shape clear below.
cleared = empty_traffic_data()
self.proxy.traffic_data = cleared
if self.proxy.socketio and self.proxy.connected_clients > 0:
self.proxy.socketio.emit("acdata", cleared)

self._emit_node_info()

# Emit POLY data for the newly active node
Expand Down
42 changes: 0 additions & 42 deletions frontend/src/core/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,17 +113,11 @@ export class App {
}
}

/**
* Initialize application state
*/
private initializeState(): void {
this.setupConnectionStatusHandlers();
this.setupSimulationDataHandlers();
}

/**
* Set up handlers for connection status changes
*/
private setupConnectionStatusHandlers(): void {
// ConnectionStatusService is the single source of truth for all
// connection state.
Expand All @@ -141,9 +135,6 @@ export class App {
// (handled in checkInitialConnectionStatus method).
}

/**
* Set up handlers for simulation data changes
*/
private setupSimulationDataHandlers(): void {
// Header shows simulation time, rate, etc.
this.stateManager.subscribe('simInfo', (newSimInfo) => {
Expand Down Expand Up @@ -194,10 +185,6 @@ export class App {
});
}

/**
* Initialize UI components
* Sets up UI modules and their event handlers
*/
private initializeUI(): void {
this.initializeHeader();
this.initializeConsole();
Expand Down Expand Up @@ -232,19 +219,13 @@ export class App {
});
}

/**
* Initialize header component
*/
private initializeHeader(): void {
this.header.init();
this.header.setSocketManager(this.socketManager);
this.header.setStateManager(this.stateManager);
logger.debug('App', 'Header initialized');
}

/**
* Initialize console component
*/
private initializeConsole(): void {
this.console.setStateManager(this.stateManager);
this.console.setCommandHandler(this.commandHandler);
Expand All @@ -254,9 +235,6 @@ export class App {
logger.debug('App', 'Console component initialized');
}

/**
* Initialize control panels
*/
private initializeControlPanels(): void {
this.simulationNodesPanel.init();
this.simulationNodesPanel.setSocketManager(this.socketManager);
Expand Down Expand Up @@ -305,9 +283,6 @@ export class App {
return el;
}

/**
* Initialize map display
*/
private initializeMapDisplay(): void {
this.mapDisplay.initialize();
this.mapDisplay.setupStyleSelector();
Expand Down Expand Up @@ -402,26 +377,18 @@ export class App {
logger.debug('App', 'Map display and interaction managers initialized');
}

/**
* Initialize modal dialogs
*/
private initializeModals(): void {
modals.forceInitialize();
logger.debug('App', 'Modal system initialized');
}

/**
* Set up global event listeners
*/
private setupGlobalEventListeners(): void {
const { signal } = this.globalListenerAbort;

// Handle before page unload
window.addEventListener('beforeunload', () => {
this.cleanup();
}, { signal });

// Handle resize events
window.addEventListener('resize', () => {
this.handleResize();
}, { signal });
Expand Down Expand Up @@ -479,9 +446,6 @@ export class App {
});
}

/**
* Handle window resize
*/
private handleResize(): void {
if (this.mapDisplay && this.mapDisplay.isInitialized()) {
this.mapDisplay.resize();
Expand All @@ -501,9 +465,6 @@ export class App {
}
}

/**
* Send command to simulation
*/
public sendCommand(command: string): Promise<boolean> {
return this.socketManager.sendCommand(command);
}
Expand All @@ -527,9 +488,6 @@ export class App {
return this.aircraftInteractionManager;
}

/**
* Set active simulation node
*/
public setActiveNode(nodeId: string): void {
this.socketManager.setActiveNode(nodeId);
this.stateManager.setActiveNode(nodeId);
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/ui/CommandHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ describe('CommandHistory', () => {
expect(history.entries).toEqual(['b', 'c', 'd']);
});

it('skips consecutive duplicates but keeps non-consecutive ones', () => {
['OP', 'OP', 'HOLD', 'OP'].forEach(cmd => history.add(cmd));
expect(history.entries).toEqual(['OP', 'HOLD', 'OP']);
});

it('persists across instances via storage', () => {
history.add('CRE KL123');

Expand Down Expand Up @@ -72,5 +77,29 @@ describe('CommandHistory', () => {
const empty = new CommandHistory('empty-history');
expect(empty.previous()).toBeNull();
});

it('restores the draft passed to previous() when next() steps past the newest', () => {
expect(history.previous('CRE KL204 B7')).toBe('third');
expect(history.previous()).toBe('second');
expect(history.next()).toBe('third');
expect(history.next()).toBe('CRE KL204 B7'); // draft back on the fresh line
expect(history.next()).toBeNull();
});

it('only stashes the draft when leaving the fresh line, not mid-navigation', () => {
history.previous('my draft'); // third
history.previous('not a draft'); // second - already navigating
history.next(); // third
expect(history.next()).toBe('my draft');
});

it('add() during navigation resets it so previous() starts at the newest entry', () => {
history.previous('draft'); // third
history.previous(); // second
history.add('fourth'); // e.g. a map-drawn aircraft via displaySentCommand
expect(history.previous()).toBe('fourth');
history.resetNavigation();
expect(history.next()).toBeNull(); // draft was discarded too
});
});
});
Loading